- Go 94.9%
- Dockerfile 2.6%
- Shell 2.4%
- Makefile 0.1%
|
|
||
|---|---|---|
| .forgejo/workflows | ||
| cmd/dash | ||
| e2e | ||
| examples | ||
| internal | ||
| .gitignore | ||
| .tool-versions | ||
| AGENTS.md | ||
| go.mod | ||
| go.sum | ||
| Makefile | ||
| podman-in-podman.md | ||
| README.md | ||
dash
Run TUI agents (opencode, claude code, or anything else) inside a rootless container, so they have internet access but cannot touch your host filesystem beyond what you explicitly share.
dash # same as `dash run`: the default agent
dash build --podman --chromium # build the image variant (rebuild whenever you like)
dash run opencode # run a configured tool in the sandbox
dash run --profile work claude # same, with a profile's config files
dash run bash # any command, with the same sandbox
Requires podman on the host and Go to build dash (make build).
How it works
- Image (
internal/assets/Containerfile): a generic Debian slim (docker.io/library/debian:trixie-slim) based image with nodejs/npm (for the agents), git, curl, and a user that mirrors your host user (same name, uid, gid and home path). Optional podman and chromium are build arguments, so one Containerfile produces every variant:dash:base,dash:podman,dash:chromium,dash:podman-chromium. - Run (
dash run): starts a--rmcontainer with your current directory mounted at the same path, your shared dev tools, your volumes, the profile and the tool. The entrypoint prepares the environment and execs the tool.
Where do the TUIs live? (answer: a volume, not the image)
Tools are declared in dash.yaml with an install script:
tools:
opencode:
install: npm install -g --prefix "$DASH_TOOLS" opencode-ai@latest
On the first dash run opencode the script runs inside the sandbox and
installs into $DASH_TOOLS, a named podman volume (dash-tools-<image>-<variant>).
Later runs reuse it. Consequences:
- Nothing is installed on the host and the image stays generic.
- No rebuild to update an agent: change the version in
install(the install is keyed by a hash of the script, so editing it triggers a reinstall), or remove the volume withpodman volume rm dash-tools-…to force one. - Installs happen with the sandbox environment, so
npmcomes from your shared asdf nodejs.
If you prefer baking something into the image, add it to image.extra_packages
(apt packages) and rebuild.
Shared with the host
shared.asdf and shared.aqua (both on by default) mount the host dev-tool
directories read-write at the same path, and put their shims/bin directories
on PATH:
- asdf:
$ASDF_DATA_DIR(default~/.asdf), plus theasdfbinary's directory when it lives under your home (shims call it). - aqua:
$AQUA_ROOT_DIR(default~/.local/share/aquaproj-aqua), the aqua config (aqua.yaml, read-only, exported asAQUA_GLOBAL_CONFIG; if the host already setsAQUA_GLOBAL_CONFIG, that file is used instead and mounted read-only at the same path), plus theaquabinary's directory when under your home.
Binaries are only shared from under $HOME, so a system directory is never
mounted over the image.
Clipboard shared with the host
Copy and paste in the sandbox use the host's clipboard, on Wayland, X11 or WSL.
Pi can paste images from the host clipboard as well as text.
The sandbox commands wl-copy, wl-paste, xclip and xsel relay through a
socket that the dash run process serves. The host runs the copy or paste command,
so the sandbox never reaches the display server and cannot read keystrokes.
The host command is chosen in this order:
-
clipboard.copyandclipboard.paste, when set (both or neither). They run withshon the host, so a custom binary works, e.g. under WSL:clipboard: copy: win32yank.exe -i --crlf paste: win32yank.exe -o --lf image: "powershell.exe -NoProfile -Command 'Add-Type -AssemblyName System.Windows.Forms,System.Drawing; $i=[Windows.Forms.Clipboard]::GetImage(); if ($i) { $m=New-Object IO.MemoryStream; $i.Save($m,[Drawing.Imaging.ImageFormat]::Png); $b=$m.ToArray(); [Console]::OpenStandardOutput().Write($b,0,$b.Length) }'"imageis what lets pi paste images under WSL; without it, images need the Wayland or X11 tools. The default commands read image data directly, so theimagekey is not needed there. -
wl-copy/wl-pastewhenWAYLAND_DISPLAYis set and they are installed, -
else
xclip, thenxsel, whenDISPLAYis set.
The sandbox also gets the host's WAYLAND_DISPLAY or DISPLAY, since pi only uses the
clipboard commands when one is set. Its value is not a working display: GUI apps in the
sandbox cannot connect to it.
clipboard.enabled: false turns it off. The sandbox image must contain the
commands: run dash build after upgrading dash.
Custom volumes
volumes:
- source: ~/.cache/go-build # host path, mounted at the same path
target: ~/.cache/go-build
- source: dash-npm-cache # bare name = podman named volume
target: ~/.npm
- source: ./docs # relative to dash.yaml
target: /docs
readonly: true
Global volumes apply to every run; a profile can add more. For one run, pass
dash run --volume PATH (repeatable): the path is mounted at the same path, and
--volume PATH:ro mounts it read-only. Relative paths are relative to the
current directory, and the path must exist. Sources can be
files or folders. An existing file is mounted as is; a missing host path is
created as a directory.
Caches, home directories and CA certificates
Host paths that already exist are passed through read-write at the same path, so the sandbox reuses your existing caches and tool state. Missing paths are skipped, so one config works on every machine.
caches: # caches: shared warm build/download caches
- ~/.cache/go-build
- ~/.npm
home_dirs: # non-cache state under $HOME (not caches)
- ~/.cursor
pass_node_extra_ca_certs: true # default: forward NODE_EXTRA_CA_CERTS (see below)
shared:
agent_configs: true # default: pass ~/.pi, ~/.claude, ~/.claude.json, ~/.codex
Entries must be absolute or start with ~. NODE_EXTRA_CA_CERTS, when set
on the host, is forwarded and its certificate file is mounted read-only at the
same path. A missing or relative file fails the run. Set
pass_node_extra_ca_certs: false to disable it.
Company CA certificates
If your network inspects TLS with a company proxy, list its CA certificate
(PEM) under ca_certificates:
ca_certificates:
- ~/certs/company-proxy.pem
The files are installed into the image at dash build time, in the system
certificate store (update-ca-certificates), so curl, git, Go, Python and
Node's system CA set all trust them. Sandboxes start without any extra work.
Changing the list needs a rebuild: dash build. A missing or relative file
fails the build. Node's NODE_EXTRA_CA_CERTS remains a separate option.
Agent state (~/.pi, ~/.claude, ~/.claude.json, ~/.codex) is passed
through the same way by default, so logins and settings are shared. Set
shared.agent_configs: false to turn this off.
Cache sizes and cleanup: dash cache
dash cache lists the size of each cache dash uses, and dash cache clean removes
them:
dash cache # same as `dash cache list`
dash cache clean build tools # any of: build, tools, store, host
dash cache clean all
| Name | What it is | clean removes |
|---|---|---|
build |
the package cache of the image build (--mount=type=cache, dash-apt-lists and dash-apt-archives), kept by buildah in /var/tmp/buildah-cache-<uid> |
that directory; the next dash build downloads the packages again |
tools |
the dash-tools-* volumes where agents are installed |
the volumes; the next dash run reinstalls the tools |
store |
the shared image store of the podman variant (dash-podman-store, see above) |
the volume, so every shared image is removed; refused while a sandbox is running |
host |
the existing host directories listed in caches: |
their contents (the directories are kept) |
Cleaning build makes the next dash build slower, and cleaning tools makes the
next dash run reinstall the agents. Host podman images are never touched; store is the only podman data it removes.
Git worktrees: work in a branch, merge at exit (opt-in)
Off by default: inside a git repository, dash mounts your checkout as usual.
Pass --worktree to dash run, or set worktree.enabled: true, to get the
following. Started inside a git repository, dash then does not give the sandbox
your checkout. It creates a git worktree on a new branch dash/<timestamp>, starting from
HEAD, and mounts that worktree at the repository's path. The agent sees the
repository where you expect it, and your checkout is untouched while it runs.
When the sandbox exits, dash:
- stashes the uncommitted changes of the worktree, including untracked
files (
git stash listshows them asdash: unstaged changes from …); - removes the worktree;
- rebases the commits made on the branch onto
main, then fast-forwardsmainto the branch (in the checkout wheremainis checked out), somaingets a linear history with no merge commit. The branch is deleted.
Nothing is lost: if the rebase conflicts, or the fast-forward cannot run (for
example, your own unstaged edits touch the same files), the rebase is aborted
and the commits stay on their dash/* branch, which dash names in its message. If the changes
cannot be stashed, the worktree is kept.
Ignored files created in the worktree (build output, node_modules) are removed
with it.
To keep the commits on their branch and not merge them, pass --no-merge or set:
worktree:
enabled: false # default: false. true runs the sandbox in a new worktree (same as --worktree)
merge: true # default: true. false keeps the commits on dash/* branches
Outside a repository, or in a repository without commits yet, dash mounts the
directory as before. Worktrees live in $XDG_DATA_HOME/dash/worktrees.
When the worktree's git directory is inside the repository (the usual case),
it is mounted in the sandbox at a separate path, and the worktree's .git file
is replaced by a copy that points there. Your host files are not changed. A
linked worktree you started dash in gets the same treatment, so git works in
the sandbox either way.
Menu over the sandbox: ctrl+space
In an interactive dash run, press ctrl+space to open the menu. Inside tmux
it is a tmux menu; outside tmux it is drawn over the agent's screen:
| Key | Action |
|---|---|
s |
shell in the running sandbox (podman exec, in the sandbox working directory) |
h |
shell on the host, in the worktree (or the current directory) |
g |
lazygit on the host, in the worktree (or the current directory) |
n |
neovim on the host, in the worktree (or the current directory) |
p |
forward sandbox ports to the host (see below) |
esc |
close the menu |
Inside tmux, s, h, g, n and p open a popup over the client, and S, H, G
and N open a new window; the agent keeps running and its screen is never touched.
Outside tmux, s, h, g, n and p run in place of the agent and the agent's screen
is redrawn afterwards; S, H, G, N and P need tmux.
Forwarding ports (p). Lists the TCP ports the sandbox listens on (including
ports published by inner containers), then asks for a sandbox port and a host port
(same number if left empty). The port is then reachable on 127.0.0.1:<host port>
until you remove it (-HOSTPORT) or the sandbox exits. No restart is needed: dash
listens on the host and, per connection, runs podman exec -i with socat in the
sandbox. TCP only. Images built before this feature need dash build (for socat).
Choose another key with keybind: (ctrl+a to ctrl+z except ctrl+h,
ctrl+i and ctrl+m, ctrl+\, ctrl+], or off). A terminal sends the same
byte for ctrl+space and ctrl+@, so that byte opens the menu. Outside tmux,
the agent's output is held back while the menu is open and redrawn when it closes.
Running any command: dash run -- <cmd>
Everything after -- is the command, flags included, and runs interactively
in the sandbox with the same mounts and environment:
dash run -- bash
dash run -- bash -c 'echo hi' --x
dash run -- with no command is an error.
Start menu: dash start
dash start asks one prompt built from the start section of the config and runs
dash run with the answers. A final choice ends the prompt: its args are the
command (after --), empty meaning the default tool. A toggle choice adds its
args (flags) to the run, on or off each time it is picked, and keeps the prompt open:
start:
- key: n
label: new
type: final
- key: c
label: continue
type: final
args: [pi, -c]
- key: w
label: worktree
type: toggle
args: [--worktree]
Typing w then c runs dash run --worktree -- pi -c. Remove the start section to
drop the command, or edit the list to add your own choices.
Profiles: config files for the tools
A profile points at a .config directory. Every entry in it (folders and files)
is copied into the sandbox ~/.config at start, plus optional env and volumes:
profiles:
work:
config: ./profiles/work/.config # its entries become ~/.config/<entry>
env: { WORK_MODE: "1" }
volumes: [ { source: ~/.cache/work, target: ~/.cache/work } ]
The directory is mounted read-only and copied, so the sandbox can edit its
copies freely but never writes back to the host. Existing files in the image's
~/.config are overwritten by a profile of the same name. Start with dash run --profile work <tool>.
Variants: podman and chromium
| Flag | Effect |
|---|---|
--podman |
installs rootless podman; the run gets a seccomp profile that lets it set up its namespaces (see below) |
--chromium |
installs chromium; /etc/chromium.d/dash adds --no-sandbox --disable-dev-shm-usage and shm_size defaults to 1g |
The default variant comes from image.podman / image.chromium. Override per
command with --podman=false etc. dash run refuses to start if the variant
isn't built, and tells you the exact dash build command. If the image was built
from a different build context (embedded assets, CA certificates, build args) than
the running dash, it starts anyway with a warning to rebuild. The image carries a
dash.fingerprint label for this check.
Trade-offs of --podman. Podman runs rootless as the sandbox user, with
no capability added and no privileged mode. Agents have passwordless sudo, so they can
install packages with apt; that root access stays inside the container. The one
relaxation is a seccomp profile written to $XDG_CACHE_HOME/dash/ at each run:
it is the podman default with sethostname, setdomainname and setns
allowed without CAP_SYS_ADMIN, because the nested runtime needs them. The inner
podman stores layers with overlay through fuse-overlayfs, which needs /dev/fuse
(the one other device dash passes in, --device=/dev/fuse). Fuse-overlayfs runs in
userspace, so it works where the kernel's native overlay cannot use the store volume as
its upper layer. Its layers live on the shared store volume (see below). Inner containers
get their own network namespace, with pasta (from passt) as the rootless network
backend, so -p publishes ports on the sandbox. Pasta creates its tap device through
/dev/net/tun, the one device dash passes into the podman variant
(--device=/dev/net/tun). A tun device only creates tap interfaces inside network
namespaces the sandbox already owns, so it gives no access to the host's network.
The sandbox runs with net.ipv4.ip_unprivileged_port_start=0, so its own processes can
bind ports below 1024. Inner containers do not get that sysctl: setting it there fails,
because the sandbox's /proc/sys is read-only. Root processes in inner containers still
bind low ports (nginx on :80 works); non-root ones cannot. Inner
containers reach the internet through pasta, over the sandbox's network. Ports they
publish are reachable from inside the sandbox; dash does not publish them to the host.
Inner containers get the sandbox's /proc bind-mounted (the sandbox masks /proc, so
a fresh mount is refused), so they see the sandbox's processes. Inner ids are limited to 0–64535 (the sandbox's own uid space, from
the subuid range in the Containerfile), so images with files owned by uid 65534
(e.g. busybox) fail to extract. On SELinux hosts the inner podman may need
label=disable; dash does not add it.
Trade-offs of chromium. Chromium's own sandbox is disabled (--no-sandbox)
because it cannot run inside a container without extra privileges. The container
is the isolation boundary.
Shared image store (podman variant)
Podman sandboxes share one named volume, dash-podman-store, which holds the image
layers and image metadata. Each sandbox links those into its own graph root, while
its containers stay local (so podman ps shows only that sandbox's containers). An
image pulled in one sandbox is available in the others without pulling it again.
What is shared, and what that means:
- Any sandbox can read every shared image and layer, including the writable layers of other sandboxes' running containers (they live in the shared volume while they exist).
- Any sandbox can change or remove shared images, which affects the sandboxes that
use them later. Inside the sandbox,
podmanrefusesrm,container rm,container prune,pod rm,pod prune,system pruneandsystem reset(/usr/local/bin/podmanwraps the real podman). This is a guardrail, not a boundary:/usr/bin/podmanis still callable. - Images are managed from the host with
dash podman <args>, e.g.dash podman imagesordash podman rmi <image>. It runs podman in a throwaway sandbox on the shared store and is refused while a dash sandbox is running.
Configuration reference
See examples/dash.yaml. Config discovery order: --config, $DASH_CONFIG, ./dash.yaml, ~/.config/dash/dash.yaml.
If none exists, dash asks which agent should be the default (pi, opencode, claude
code, codex-cli or maki), whether to include podman and chromium in the image (both default
no), writes ~/.config/dash/dash.yaml with all five agents configured in tools, and
tells you where it is. dash init asks the same questions. Without a terminal it uses
pi with no podman or chromium. default_tool is the agent that
dash run starts with no arguments; change it or remove agents you don't use. Unknown
keys are errors. Other options: mount_cwd, env, pass_env (forwarded only
when set on the host, so API keys stay out of the config file), and env_commands
(a variable is the stdout of a host command, e.g. gh auth token, run at each
dash run; the values never appear on podman's command line).
Use --dry-run on build and run to print the exact podman command.
Tests
make test # unit tests: config, planning, image args, entrypoint behaviour (no podman)
make e2e # end-to-end: builds the full and base images, runs a dummy agent in them
The e2e tests (e2e/) run dash against real podman. The dummy agent
(e2e/testdata/dummy-agent.sh) runs inside the sandbox and checks:
- the sandbox user is your host uid (bind mounts keep their owners)
- a profile file was copied into
$HOME - a tool installed into the tools volume is on
PATH - shared asdf and aqua tools run
- a custom read-write volume is writable from the sandbox and visible on the host
- the sandbox user has passwordless sudo, and no
SYS_ADMINorNET_ADMINcapability - headless chromium loads a page served from the host
- the internet is reachable
- rootless podman runs, and it starts a container inside the sandbox
- the host working directory is mounted at its own path
- a read-only volume cannot be written
- host env vars and files outside the mounts are not visible
The nested podman test (e2e/podman_test.go, podman variant) runs
testdata/nested-podman.sh in the sandbox: it pulls an image, runs nginx and
checks it serves on port 80, publishes a port with -p 8080:80 and fetches it
from the sandbox, bind-mounts a host directory into a nested container, rejects a
pull of a missing image, and stops a container.
The base variant test checks that podman and chromium are absent from the
plain image. Two worktree tests (base image) check that --worktree merges the
session commit into main, stashes uncommitted work and removes the worktree,
and that --no-merge leaves main alone and keeps the commit on its dash/*
branch.
The e2e suite is not run in CI; run it locally with make e2e.
Layout
cmd/dash/ entry point
internal/cli/ commands and flags
internal/config/ dash.yaml loading and validation
internal/sandbox/ planning of `podman run` (mounts, env, variant flags)
internal/image/ `podman build` arguments
internal/assets/ Containerfile, entrypoint, podman/chromium/clipboard config (embedded)
internal/clipboard/ clipboard shared with the host: socket server and host commands
internal/host/ host user, paths and environment (injectable for tests)
internal/keybind/ parsing of the menu key
internal/overlay/ pty proxy and the menu over the sandbox (see ctrl+space)
internal/worktree/ git worktree per session: creation, stash, merge and cleanup
internal/podman/ podman CLI helpers
internal/cache/ sizes and cleanup of the caches (`dash cache`)
e2e/ end-to-end tests and the dummy agent
examples/ sample dash.yaml and profile
Home directory and environment file
home: tmpfs gives the sandbox an empty, ephemeral home directory (podman run --tmpfs).
Only what you mount or copy in (profiles, caches, home_dirs) exists there, and nothing
written during the run survives it. The default, home: persistent, uses the image's home.
env_file names a host file of KEY=VALUE lines, passed to podman with --env-file.
Relative paths are relative to the config file. A missing file fails the run. Values
never appear on podman's command line, unlike env.
Starting dash run from your home directory mounts an empty temporary directory as the
working directory instead of the whole home.