No description
  • Go 94.9%
  • Dockerfile 2.6%
  • Shell 2.4%
  • Makefile 0.1%
Find a file
mathieu ed4901b7f7
All checks were successful
ci / test (push) Successful in 1m20s
fix: clipvoard
2026-11-08 12:32:54 +01:00
.forgejo/workflows Run e2e tests in CI and cover sandbox isolation (#8) 2026-11-02 14:43:02 +00:00
cmd/dash init 2026-11-01 23:31:38 +01:00
e2e passwordless sudo 2026-11-08 01:02:41 +01:00
examples Paste images 2026-11-08 12:11:49 +01:00
internal fix: clipvoard 2026-11-08 12:32:54 +01:00
.gitignore init 2026-11-01 23:31:38 +01:00
.tool-versions Align dash CI with proj pipeline (#1) 2026-11-01 23:29:06 +00:00
AGENTS.md fix: menu shortcut 2026-11-07 19:15:46 +01:00
go.mod dash overlay keybind and popup 2026-11-02 23:19:41 +01:00
go.sum Fix asdf bothering node install 2026-11-01 23:48:12 +01:00
Makefile init 2026-11-01 23:31:38 +01:00
podman-in-podman.md overlay fs instead of vfs 2026-11-06 20:07:42 +01:00
README.md Paste images 2026-11-08 12:11:49 +01:00

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

  1. 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.
  2. Run (dash run): starts a --rm container 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 with podman volume rm dash-tools-… to force one.
  • Installs happen with the sandbox environment, so npm comes 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 the asdf binary'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 as AQUA_GLOBAL_CONFIG; if the host already sets AQUA_GLOBAL_CONFIG, that file is used instead and mounted read-only at the same path), plus the aqua binary'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.copy and clipboard.paste, when set (both or neither). They run with sh on 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) }'"
    

    image is what lets pi paste images under WSL; without it, images need the Wayland or X11 tools. The default commands read image data directly, so the image key is not needed there.

  • wl-copy/wl-paste when WAYLAND_DISPLAY is set and they are installed,

  • else xclip, then xsel, when DISPLAY is 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:

  1. stashes the uncommitted changes of the worktree, including untracked files (git stash list shows them as dash: unstaged changes from …);
  2. removes the worktree;
  3. rebases the commits made on the branch onto main, then fast-forwards main to the branch (in the checkout where main is checked out), so main gets 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, podman refuses rm, container rm, container prune, pod rm, pod prune, system prune and system reset (/usr/local/bin/podman wraps the real podman). This is a guardrail, not a boundary: /usr/bin/podman is still callable.
  • Images are managed from the host with dash podman <args>, e.g. dash podman images or dash 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_ADMIN or NET_ADMIN capability
  • 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.