Why Cleat #
AI coding agents are at their best with full permissions. They install, build, test and
iterate without stopping to ask. That is exactly what --dangerously-skip-permissions
unlocks in Claude Code. On your actual machine it is a disaster waiting to happen: one wrong
command can overwrite system files, read your credentials, or reach into every other project you
have. Cleat gives the agent that same unrestricted flag, safely, inside a container that cannot
touch your host.
Without isolation
- ✗ modifies /usr/local, overwrites /etc/hosts
- ✗ reads ~/.ssh, tokens, every secret you have
- ✗ reaches into your other projects
Secrets leaked. Home folder wiped. No undo.
Inside Cleat
- ✔ install, build, delete, run anything
- ✗ cannot touch your host filesystem
- ✗ cannot reach your other projects
8 hours later · 47 files changed · host untouched.
Why not just write a Dockerfile?#
You could. But then you handle UID/GID mapping, clipboard bridging, auth sharing, session persistence, per-project container naming and a dozen edge cases. Cleat does all of it in one command and keeps your files owned by you, not root.
Install #
Requirements
- Docker. Cleat starts it for you when it's down and offers to install it when it's missing. Prefer to set it up yourself? Get Docker, then confirm with
docker run hello-world. - macOS or Linux (Windows via WSL2). On macOS, Docker Desktop is the easy way to get Docker. Cleat also runs on plain Docker Engine, so it doesn't require Desktop.
- An Anthropic account (Pro, Max, Team, or Enterprise) or an API key. Already signed into Claude Code on this machine? Cleat reuses that login automatically.
- git, used by the install script and by
cleat updateon a script install. A Homebrew keg needs neither.
This clones Cleat to ~/.cleat, checks out the latest stable tag and symlinks
cleat onto your PATH.
Or with Homebrew:
Prefer to read it first? curl -fsSL https://cleat.sh/install -o install.sh, inspect
it, then run bash install.sh, or clone
the repo
and run ./install.sh yourself. If you cloned or moved the script, cleat install
symlinks it onto your PATH and cleat uninstall removes it. Then drop into any project and go:
The first run downloads a prebuilt image once (later runs reuse it instantly), starts the container, shares your Claude auth and launches Claude, locked down by default:
✔ Image ready (pulled v1.4.3)
✔ Container started
✔ Auth shared
✔ Claude launched
Container: cleat-my-app-a1b2c3d4
Project: ~/code/my-app → /workspace
No git identity, no SSH keys, no env files, no Docker socket: just your repo at
/workspace and Claude's auth. You add access deliberately with
cleat config (next sections).
That → /workspace is the locked-down default.
Grant the docker cap and the Project line instead reads
(same path, sandboxed). Your repo mounts at its real host path so in-container
docker commands resolve on the host daemon. Two things move it: that cap plus
--fork, which points /workspace at the box's own copy and adds a
Fork: line to the summary. See
what a configured start shows.
Signing in#
If you already use Claude Code on this machine, you're set. Cleat mounts your existing
~/.claude login, so nothing else is needed. If Claude asks you to log in, run
cleat login once the container is up: the OAuth page opens in your browser and the
callback is proxied straight back into the container. You only log in once and it's shared across
every project and box.
If the first run fails#
- Docker isn't running: Cleat starts it for you (Docker Desktop, OrbStack, or Colima on macOS. Desktop or a rootless engine on Linux. The Windows-side Desktop from WSL2), waits, then continues your command. No
dockerCLI at all? Cleat offers to install one. Where it can't start Docker (root-owned engine, script,CLEAT_NO_AUTOSTART=1), it prints the exact command to run instead. - "permission denied … /var/run/docker.sock" (Linux): your user isn't in the docker group. Run
sudo usermod -aG docker $USER, then log out and back in. - Claude asks you to log in: run
cleat login(see above).
Quickstart #
The daily loop is six commands. They all act on the current directory.
Sessions live on your host, not in the container, so cleat rm never loses your
work. cleat resume recreates a fresh container and picks up exactly where you
left off. Once a container is running, cleat login signs you in: the OAuth page
opens in your browser and the callback is proxied straight back into the container.
Capabilities #
By default the sandbox is sealed. Capabilities are opt-in grants that extend what it can reach
on your host. Each one is off until you turn it on. Manage them with cleat config:
The editor's global scope has one more row: generate a project .cleat.
It stamps your current caps and resource limits into ./.cleat so a per-project cage is one keystroke,
not an INI you write from memory. It preserves an existing [setup] section and does not auto-trust:
the generated file goes through the normal trust prompt on the next run, exactly like a repo-shipped one.
git mount Mounts your ~/.gitconfig read-only. Commits carry your name and email. Identity only: it grants no repo access.
ssh mount Mounts ~/.ssh read-only and forwards your SSH agent ($SSH_AUTH_SOCK). push, pull and clone private repos work. Reaches every host your keys can. A real privilege grant, which is why it is separate from git.
env mount Loads variables from ~/.config/cleat/env and .cleat.env. With it off, those files are ignored entirely.
hooks mount Runs your Claude Code hooks on the host through a bridge, uncontained, as you. The agent generates the events that trigger them. A hook command naming a path in the repo is a file the agent can edit. With it off, hooks are stripped from the container so it cannot run host commands.
gh mount Mounts ~/.config/gh read-write so gh auth login persists. The token lives on the host and survives rm, nuke and rebuild.
docker sandbox Binds your engine’s real daemon socket so the agent can run docker compose and docker run: the in-VM socket for Docker Desktop, OrbStack, Colima, Rancher Desktop and Lima, the real host socket for rootful and rootless Linux. Refuses a missing socket instead of leaving a phantom directory. Breaks isolation: socket access is root-equivalent on the daemon. Off by default, loud warning on every start.
mount capabilities give the container something from
your host (files for git, ssh, gh and hooks, plus environment variables for env), while
sandbox capabilities change the isolation boundary
itself. There is exactly one of the latter, docker. Cleat warns loudly on every
start.
What a configured start shows#
Turn caps on and the summary lists exactly what's mounted, grouped by category. The
docker cap also mounts your workspace at its real host path, so the Project line
reads (same path, sandboxed) instead of → /workspace. A loud
warning also prints on every start:
✔ Image ready (cached)
✔ Container started
✔ Auth shared
! Docker socket mounted. Container can create host-level processes
✔ Claude launched
Container: cleat-my-app-a1b2c3d4
Project: ~/code/my-app (same path, sandboxed)
Caps:
mount: git, ssh
sandbox: docker (breaks isolation)
Changing a capability recreates the container.
Docker mounts are fixed at create time, so after a config change the next
cleat notices the drift and asks to recreate. Your session survives the recreate.
Upgrading Cleat does not. A new CLI version only prompts to refresh the image when the image's contents actually change (the entrypoint, the bridges, the base). A version-only release leaves your container and everything you installed in it untouched.
Boxes #
new in v0.14.0You can skip this until you need it. A single default sandbox is all most people ever use. Boxes are here for when one project needs more than one posture at once.
A box is a named sandbox for the current project. By default every box mounts the same live
/workspace (the same uncommitted files), but each gets its own capabilities,
trust, writable layer and Claude session. A fork box
is the exception: it mounts its own copy instead. The grammar is cleat <verb> [box].
The token after the verb is always a box name, never a path.
This buys you least privilege within a single repo. Run a locked-down editing box beside a cloud-capable one over the same code. The editing box genuinely cannot reach what the other holds:
What a box may reach is declared in one place, the project's .cleat, as a section
named after the box (below). A box
can hold fewer capabilities than the project default, which
is the whole point. Trust, env files (.cleat.<box>.env) and Claude sessions are
per-box too: approving the az box never grants dev those caps, while
cleat resume dev never crosses into az's conversation.
A box is not an auth boundary for your Claude account.
Your Anthropic credentials (~/.claude) are shared read-write into every box so you
log in once. Sign in from any box (or the default) and they're all authenticated. Boxes isolate
host-capability blast radius, the writable layer and per-project Claude state, not your login.
cleat, cleat start and cleat start main all mean the same
default container.
Per-box configuration#
One project, one config file. A box scopes any section by name, so the posture of every box in the repo reads top to bottom in a single diff:
The syntax is [box.<name>.<kind>], where <kind> is
caps, resources, setup or fork. A declared
section replaces the bare one. An absent section
inherits it. Declared but empty is a real value rather than
absence, which is what preserves least privilege: [box.locked.caps] with nothing
under it means zero caps, not "inherit everything". caps, setup and
fork are lists that replace wholesale. resources is key by key, so
declaring memory for a box leaves its cpus inherited.
[box.main.<kind>] is honoured like any other box, never special-cased.
Two things stay outside those sections. Env vars keep their own sidecar file
(.cleat.<box>.env, falling back to .cleat.env), so an
az box's credentials are never visible to a dev box. And per-box
sections are project-only: box names mean nothing outside a project, so a [box.*]
section in your global config is ignored with a warning. Resource values from a repo file are
still clamped (8 GB memory, your machine's core count for cpus), per-box sections included,
so a .cleat asking for 16g still runs at 8g.
Trust follows the section. The capability hash is per box, so editing one box re-prompts that box
alone and the prompt names the subject
it is approving. cleat config <box> edits those sections for you. Enabling a cap
on a box that is still inheriting writes the inherited set out first, so
cleat config review --enable gh against a project [caps] of
git ssh leaves that box on git ssh gh. Edit a box back to exactly what it
would inherit and the section is removed, so it inherits again. Empty it and the bare header stays,
because dropping it would hand a locked-down box the project's full set the next time the project
gains a cap. cleat config <box> --list marks every value
declared or inherited and names the box it is showing.
On an older Cleat, a per-box reduction fails open.
A CLI older than v1.4.0 cannot see [box.*] sections at all. It falls back to
[caps], so a teammate on an older version runs your locked-down box with the
permissive project set. The first time a per-box section appears, Cleat writes a two-line
comment into the file naming the version that reads them. If a lockdown has to hold across a
mixed-version team, put the restrictive set in [caps] and widen the boxes that need
more.
Forked workspaces #
new in v1.4.0
A box normally shares your live project directory. --fork gives it its own
copy, so an agent can work without touching your tree.
Run it a few times and you have several agents on one project, each in its own container
working on its own files. They still share what every box shares: your Claude login and the
host ~/.claude/plugins.
It is a copy rather than a git clone, so submodules, untracked sibling repos, uncommitted
work and node_modules all come along. A project with no git at all works
the same way. Symlinks are copied as symlinks and never followed, so a project holding
sub/keys -> ~/.ssh does not put real key bytes in the cage. On macOS the copy
is copy-on-write, so it is close to instant and costs almost no disk until something changes.
The launch summary names the copy and how old it is, so a stale fork is never silent:
Container: cleat-myproj-2f96c884-feat-a
Project: ~/code/myproj (not mounted, this box works on a copy)
Fork: ~/.config/cleat/forks/cleat-myproj-2f96c884-feat-a → /workspace (copied 3h ago)
A box's workspace is fixed when the container is created, so the flag only does something at
create time. Passing --fork to a box that already exists as a plain box is
refused rather than quietly ignored, with cleat rm as the remedy. Forking a
fork, or putting the fork root inside the project so the copy would contain itself, is
refused too. cleat rm frees the container and keeps the copy, because it may
hold the only version of the work. Because it is kept, starting the box again with
--fork reuses that copy rather than taking a fresh one, so a change to
[fork] exclude does not apply until you refresh it.
The copies outlive their boxes on purpose, so they get their own verb. Note that
fork is a verb here while --fork stays a flag on
start and run. cleat fork start feat-a and
cleat start feat-a --fork are the same command, because fork is
the noun people reach for. Both start and run are offered because
the difference matters: run creates the box then returns you to your shell
while start creates it and launches Claude in it.
$ cleat fork
Fork workspaces in ~/.config/cleat/forks
cleat-demo-ab8ed4e5-feat-a 412 MB 3h ago box exists
cleat-demo-ab8ed4e5-feat-b 12 MB 2d ago no box
2 copies, 424 MB apparent.
cleat fork path feat-a prints one copy's path and nothing else, so
cd "$(cleat fork path feat-a)" works. cleat fork rm deletes one
copy. cleat fork prune deletes copies whose container is gone, plus any stale
fork marker. cleat fork refresh replaces a copy with a fresh one from the
live project.
Size is apparent, not reclaimable: du is
not copy-on-write aware, so a fresh copy reports its full size while sharing nearly every
block with the project. rm and refresh refuse while the box
exists, because its container has the copy mounted at /workspace. Both confirm,
defaulting to no. Both say plainly that uncommitted agent work in the copy will be lost.
Exclude what you do not want with [fork] exclude = node_modules in
.cleat. An exclude that is an absolute path, contains .., names the
workspace root, or resolves outside the copy through a symlink is refused with a warning
instead of being deleted. [fork] dir moves the fork root if your projects live
on another volume, since copy-on-write only works within one. It is read from your
global config only and must be an absolute path: a
project .cleat cannot move it, because that file arrives with a cloned repo and
the value is a path Cleat creates and deletes under.
A fork is a snapshot. Landing it is yours.
The copy is taken once, so a fork from an hour ago does not have work you did in the live
tree since. Without copy-on-write (Linux without reflink support, or a fork root on another
volume) it is real duplicated disk. cleat storage will not show it either: that
command measures the Docker store, while the copies live on your filesystem. Cleat copies
out. It does not merge back.
Kits #
new in v1.2.0
A kit is a curated Claude Code pre-configuration (a
CLAUDE.md policy plus custom subagents) enabled for one box with one command: the setup
power users hand-build and the first feature that makes a Cleat box
a better place to run Claude, not just a safer one.
The flagship, plan-big-execute-small, adapts the
coordinator pattern from Anthropic's cookbook
(big models for planning, small models for execution): your session's model (pick Fable 5
once with /model) plans and reviews every result while
worker and scout subagents (Sonnet 5 by default) carry the
mechanical bulk at the worker model's rate, each in its own context
window, so the session stays lean and heavy work burns your rate limit far slower. It
merges over your own config, never touches your host and cleat kit show
prints every line before you enable it. To verify it is routing, check
/usage after a heavy session: the bulk of tokens should sit on the
worker model.
A kit merges on top of your own setup inside the box.
Your global CLAUDE.md content comes first, byte for byte, then a short marked
Cleat box notes section every box carries (the
clipboard-bridge rules: copying to your host clipboard works, reading it back from the shell does not), with the
kit's section clearly marked below it. Your own agents sit beside the kit's (a same-named
agent of yours wins). Your commands, skills and settings still read exactly as they do on your
host, through a read-only copy. And it is
per box: main can stay vanilla while a
refactor box runs the planner kit on the same repo.
Your host is never written. The kit exists only as read-only files masked over
~/.claude/CLAUDE.md, ~/.claude/agents,
~/.claude/commands, ~/.claude/skills and
~/.claude/plugins inside
the container. Native claude on your machine never sees it and the caged
agent cannot rewrite its own instructions. Other projects are invisible too: a box gets a
generated ~/.claude/projects holding only its own sessions, so it cannot read
another repo's transcripts, while its own session stays writable so --continue
keeps working. A read-only mask would not be enough there: measured, :ro blocks
writes while leaving a sibling project's transcript perfectly readable, so these are generated
replacements and the host content is simply never mounted. The same treatment covers
file-history, paste-cache, uploads, backups,
shell-snapshots, sessions, tasks, jobs and
hooks. hooks is the sharpest of them, because the
hooks capability runs your hook commands on the host
and the usual way to write one names a script under ~/.claude/hooks/, so a box
able to write there could rewrite what your host runs. What a box can still reach is the
project you mounted plus your Claude login, which it needs in order to authenticate. Kits contain instructions and subagents only. No
hooks, no settings, nothing that executes as a program and cleat kit show prints
every line before you enable one.
Kit changes take effect at the next session
(Claude reads its config at session start) and enabling, switching, or disabling one never
recreates the box. One exception: a box created before kits existed lacks the mount points, so
the first enable there offers a one-time rebuild. Inside any box created since kits (kitted or
not), your user-level ~/.claude memory, agents, slash commands, skills and plugins
are read-only views (a hardening side effect: the caged agent still reads them but can no longer
rewrite any of the five instruction surfaces your host claude obeys). Author agents, commands and
skills you make in a box at project level (.claude/agents/,
.claude/commands/, .claude/skills/) instead. Plugins are the one
exception to "read it, just don't write it": installing a new one from inside a box fails,
so install plugins on the host and every box sees them.
Image paste #
new in v1.4.0
Copy a screenshot on your host, click into a box's Claude prompt and press ctrl+v.
About a second later the prompt shows [Image #1], exactly as it would outside
Docker. Pasting a picture into a caged agent just works.
# screenshot copied on your host, then ctrl+v in the box
> what's wrong in this trace? [Image #1]
It is ctrl+v, not cmd+v. That is fixed. Claude Code inside the box
reports platform linux, so it binds image paste to ctrl+v. On a Mac,
cmd+v is the terminal's own text paste and never reaches the image handler. The
feature is on by default. It needs no config and no recreate: every existing box picks it up on
its next session.
Images only, enforced on the host.
A box with a session attached can ask your host for the clipboard image at any time, so this is
a real, bounded grant. The gate that keeps it to pictures lives outside the cage where the agent
cannot reach it: the host reader names only image formats (PNG, JPEG, GIF, WebP), checks the
bytes and caps the size, so clipboard text (where passwords and tokens live) never crosses into
the box. Turn the whole thing off with CLEAT_NO_CLIPBOARD_IMAGE=1, which removes
the shim and restores the pre-v1.4.0 behavior.
Provisioning #
new in v1.3.0
Every box starts from the same base image. Anything the agent installs by hand survives
stop and start, then disappears the first time the box is recreated.
A [setup] section in your project's .cleat is the durable form: real
shell commands that Cleat runs once inside a fresh box, before the session starts, so a
recreated box comes back with your toolchain already on it.
A line is either a shell command, run verbatim, or script <path>, which inlines
a project-relative file at that position. Mix them freely, in any order: they become one payload
that runs top to bottom. A script target has to be a real file inside the project,
so absolute paths, .. segments, symlinks and anything resolving outside the project
root are all refused before a single command runs. Keeping the steps in a file means your
teammates and CI run the identical bytes you approved.
What actually runs#
The payload is staged into the box and executed as
coder (passwordless sudo, full network access) with /workspace as the
working directory, under bash -e, with stdin closed. Three consequences worth
planning for. The first failing command stops the rest.
Nothing can sit waiting for input. An export reaches later lines of the same
payload but nothing afterwards. Output streams live, so you watch it install.
▸ Project .cleat [setup] wants to run 3 command(s) in the box as coder (full network access)
sudo apt-get update
sudo apt-get install -y build-essential postgresql-client
npm ci
Run this project's setup commands? [y/N] y
✔ Container started
▸ Running project setup (3 command(s), [setup] in .cleat)
✔ Setup applied (3 command(s))
✔ Claude launched
Approving it#
A .cleat lives in the repo, so it is untrusted input. Running arbitrary shell as
a sudo-capable user is a materially bigger grant than mounting a file. [setup] gets
its own consent class because of that. Trusting a project's capabilities never trusts its
[setup]. The reverse holds too. CLEAT_TRUST_PROJECT deliberately does
not cover it: only CLEAT_TRUST_SETUP or --trust-setup does.
The first time a project declares [setup], or after any edit to it, you get a
default-no prompt showing the exact payload before
anything runs. Approval is byte-exact and covers every inlined script file's
contents, so a single changed byte re-prompts. Cleat rebuilds and re-hashes the payload
immediately before executing it, which means an agent that rewrites
/workspace/.cleat mid-session cannot ride your earlier approval. Declining never
blocks the box: it starts without provisioning and tells you how to approve later.
If the commands themselves fail, the box still opens. You get an amber warning naming the exit code, nothing is marked applied and the next start tries again from the top, so write the steps to be safe to re-run.
Editing [setup] never recreates the box.
Run-once is tracked by a marker file inside the container holding the payload's hash, so a
recreate loses the marker and re-provisions automatically while an edit reapplies in place.
Capabilities and resource limits are the only things that can force a recreate.
Three traps worth knowing.
The base image ships no C toolchain and no apt package lists, so start with
sudo apt-get update and install build-essential if you need to
compile. The agent's session runs with ~/.local/bin first on PATH and
never sources ~/.bashrc, so install into ~/.local/bin or symlink into
it rather than editing a shell rc file. And .cleat is INI, so any line shaped like
[...] starts a new section even inside [setup]. Write
test -f x, not [ -f x ]. Leading whitespace is stripped from every
line.
[setup] is project-only and box-aware: a
[box.<name>.setup]
section replaces the plain [setup] for that box rather than merging with it, with
trust recorded per box. Starters for
.NET, Python and Rust live in
examples/,
one showing inline commands, one a single script file and one two scripts plus a
trailing inline command.
Security model #
The boundary is the container itself. The agent writes only /workspace and the
container's throwaway layer. Everything else on your machine is out of reach unless you grant it.
Protects against
- Host filesystem damage: writes are confined to your repo and the ephemeral layer.
- Credential theft: SSH keys and gitconfig are absent by default, read-only when shared.
- Malicious repo config: a project's
.cleatcannot silently grant capabilities (see trust, below). - Cross-project bleed: sessions and per-project
.claude.jsonare isolated, so trust and MCP approvals never leak between repos.
Deliberately allows
- Inside the box, the agent has full power: install, build, delete, run anything.
- Claude runs with permissions auto-approved (
--dangerously-skip-permissions), safe because the container, not a prompt, is the boundary. sshreaches every host your keys can. A real grant: opt in knowingly.dockeris root-equivalent on the host. It explicitly breaks the sandbox.- Image paste pulls a picture off your host clipboard on
ctrl+v. Images only, checked and capped on the host. - Shared by design, not a boundary: your Anthropic auth and host Claude settings.
Workspace trust#
A .cleat file lives in the repo, so it is untrusted input. Cleat never applies its
capabilities silently. The first time a project requests them, you get a prompt that defaults to
no. In non-interactive contexts (CI), project caps are
dropped unless you opt in. Your global config and --cap flags are always trusted,
because you wrote them.
Trust is per (project, box). A named box hashes the capabilities it actually resolves to, its own
[box.<name>.caps]
when it declares one, so editing one box re-prompts only that box and
cleat trust <box> approves exactly that box, recording the same row its
start-time prompt would. The prompt names what it is approving, down to
.cleat [box.web.caps], because with one file per project the box name is the only
thing telling two consent decisions apart. A lone box name is always that box. A token
containing a slash is a path and selects the main box, so cleat trust and
cleat untrust stay symmetric.
Setup: a second consent class#
A project's .cleat can also declare a [setup] section: real shell commands
that run in the box as coder before the session starts. That is a materially bigger
grant than a mount, so it is approved separately, byte-exactly, with a default-no prompt that shows
the payload first. Trusting a project's caps never trusts its [setup].
CLEAT_TRUST_PROJECT deliberately does not cover it either. The full story is in
Provisioning.
Resource limits & honesty#
Each box runs with --init (a real PID 1 that reaps orphaned processes, so long
sessions can't wedge on zombie buildup and cleat stop is instant), a memory
ceiling sized to your Docker VM (a quarter of VM memory, clamped to 4–8 GB, with swap
off so a runaway process OOMs inside its own box instead of freezing every session) and
--pids-limit 4096 (a fork-bomb guard) on Docker's default bridge network. Your
host is reachable at host.docker.internal. Files the agent creates are owned by
you, via UID/GID remapping. Need more headroom for a heavy build, or want a box pinned to
fewer cores? Set it in the cleat config editor (the Resources group, arrow keys to
cycle) or with a flag, no INI required:
The editor's choices come from your actual machine. The cpus ring is built from the
core count Docker reports, so it never offers more cores than you have and never stops at 8 on a
24-core box. The memory ring climbs in real stops to the size of your Docker VM, so a
24 GB VM offers the full 24 GB. Someone who runs one box at a time is entitled to it.
Nothing above 8 GB is blocked, only annotated. The note gets blunter as the number climbs: first a reminder that a ceiling is not a reservation, then a warning once you pass half the VM, then an amber warning at the whole VM with the full reason on save. A box that really grows into the entire VM leaves nothing for the daemon or the VM's own kernel, so the Linux OOM killer inside the VM starts firing. That can take out a working agent mid-write or wedge Docker until you restart it. You are told, then you choose.
Both write the same [resources] section, so hand-editing stays a valid fallback and
the reference format. Repo-supplied values are capped (8g memory, your machine's core count for
cpus) so an untrusted .cleat can't overcommit your machine. The project ring stops at
8g for the same reason, rather than offer a ceiling the box would silently not get:
If a session is ever killed by that memory ceiling, Cleat says so (and what to do) instead
of leaving an unexplained crash. The usual culprit is a test runner: jest and
vitest start one worker per CPU and a box sees every host core, so a
small box can spawn a dozen Node workers and blow its ceiling. Cap the workers
(jest --maxWorkers=2), give the box more memory, or set
cpus so the runner self-limits.
Prebuilt images are multi-arch (Intel and Apple Silicon both run natively, never under
emulation). cleat prune clears cleat's own stale images (superseded builds and
pulls, never your boxes or other projects). Cleat watches for this itself: when
stale images pile up it offers the prune on start. Closing a terminal ends the session but
leaves the box running, still reserving its memory ceiling, so on every start Cleat first
stops other idle boxes that are safe to stop (detached, no agent running, idle past a
grace window) and tells you what it freed. A box working unattended (terminal left open) is
never touched. If running sessions still reserve more memory than the Docker VM has, Cleat
names how many are running and your real RAM and the VM, before the freeze does.
A box and a worktree are the same thing (one session, one memory ceiling), so Cleat sizes the
VM with comfortable headroom for parallel work (a ≈16 GB default, capped at half your RAM)
and, when yours is smaller, tells you the exact value to set, the click-path and your machine's
safe max, naming the Docker Desktop knobs the CLI can't touch (memory, swap, VirtioFS) and
keeps reminding you until you fix it. And because a warning that scrolls past protects no one,
an undersized VM (or default swap) actually holds the launch: Cleat stops on a
prominent banner, shows the fix and waits for you to press Enter, so you can't miss it. That
hold is one of two places Cleat blocks the start (a nearly-full Docker disk is the other,
below). It fires only on a real interactive terminal, so
cron, pipes and CI sail straight through. CLEAT_NO_DOCKER_GATE=1 skips it for a
machine that can't spare the RAM. That's a default, not a cap on sessions: a ceiling is a
limit, not a reservation, so you can run many sessions at once and the VM only swaps if a lot of
them go genuinely heavy on memory at the same time (rare and no different from Claude Code
outside Docker). When the VM is already as large as your RAM can safely back, it says so and
steers you to running fewer sessions instead of asking for memory that isn't there. It checks
swap too: memory and swap are separate Docker Desktop sliders, so if you bump memory (often
on Cleat's advice) but leave swap at the default, Cleat flags just the swap, so a brief spike
pages out instead of freezing a session. And when your VM is sized right, Cleat says so
on every start with a one-line nod (✔ Docker tuned for Cleat) right above
Image ready, so a good config gets confirmed, not just a bad one flagged.
The wording is engine-aware: on a native Linux engine there is no VM and no slider, so the
nod reads ✔ Docker ready for Cleat (16 GB RAM, ...) instead. A small host
(under 8 GB) states its size without the parallel-headroom claim.
Disk is the other pressure Cleat watches. On Docker Desktop every box shares one VM disk,
so a box that reads 100% full is really the whole store filling up, not that one box.
cleat storage shows the breakdown: your images and boxes, the shared build cache,
plus everything else labeled other projects, with a fill bar and the reclaim levers. When the
store crosses about 85% full with little free space, Cleat drops a one-line advisory. When it
crosses 95% with under 10 GB free, it holds the launch the same way the memory gate does
(CLEAT_NO_DISK_GATE=1 skips it). Each check pairs the fill percentage with a
free-space floor, so a small disk trips when it is genuinely tight while a 1.8 TB store
at 95% with 90 GB still free never does.
cleat prune --cache is the safe lever for the build cache. The fix guidance is
written for your engine: the Docker Desktop slider, OrbStack auto-reclaim, Colima resize, a
native-Linux prune or a WSL compaction.
To be candid: these are plain Docker containers, sharing your host kernel, not microVMs with a separate kernel. Cleat trades that stronger boundary for a true one-command experience, session persistence and the clipboard bridge. For most local agent work, that is the right trade. For genuinely hostile code, a separate VM is the stronger boundary.
Recipes #
Copy-paste starting points for the things people actually do.
Explore an untrusted repo#
Let the agent loose in code you don't trust. A malicious .cleat can't grant itself the Docker socket. You'll be asked first and the default is no.
Commit as you, without push access#
git is identity. ssh is reach. Enable only the first and commits carry your name while the agent still can't push anywhere.
Pass secrets without committing them#
Enabling env scaffolds a .cleat.env in your current directory (gitignore it). Even a global enable drops the file where you run it. Then declare keys: a bare key inherits from your shell. KEY=value sets it explicitly.
Run Docker from inside the sandbox#
For Docker-first apps. Containers you launch run as siblings on the host daemon. Use $(pwd) or . for mounts. The daemon sees host paths. Remember: this breaks isolation.
Two postures, one repo#
A least-privilege editing box beside a cloud box, over the same files. The dev box cannot reach the socket the az box holds. Both edits land in one file you can read and commit.
Authenticate gh inside the container#
The browser opens on your host and the token writes back to ~/.config/gh, so it survives rm, nuke and rebuild.
Upgrade the bundled Claude Code#
Cleat owns Claude's version. Upgrade in place (fast, no full rebuild), then it offers to recreate this project's container.
Notes & gotchas #
- The docker cap binds the socket where your engine actually runs. Since v1.4.3 the bind is engine-aware. A VM-backed engine (Docker Desktop, OrbStack, Colima, Rancher Desktop, Lima) gets the in-VM
/var/run/docker.sock. A host-local daemon gets its real socket, rootless included. A loopbacktcp://DOCKER_HOSTcounts as local. A socket that is not there is never bound, so a stopped daemon can no longer leave a phantom directory on the host. Remote daemons overtcp://orssh://are not a supported target: a box mounts your project by host path. A TLS remote gets a plain warning that the client certificates stay on the host. - Per-box
.cleat.<box>files are no longer read. Per-box config moved into the project's own.cleatas[box.<name>.<kind>]sections, so a week of boxes stops leaving a pile of committed dotfiles in your repo root. A leftover file changes nothing silently: it warns whenever that box starts, naming the sections to move it to. Delete it once you have. Env is the exception that stays a file, as.cleat.<box>.env. - A box never replays an old copy onto your clipboard. A copy made inside a box lands on your host clipboard while a session is attached, exactly then. Anything left over from an earlier session is discarded at the next start instead of overwriting what you currently have (before v1.2.5 it could).
- Docker down? Cleat starts it. Missing? Cleat offers to install it. A session verb that finds the daemon unreachable launches your own Docker (Docker Desktop, OrbStack, or Colima on macOS, Docker Desktop or a rootless engine on Linux, the Windows-side Desktop from WSL2), waits with a bounded spinner, then continues your command. A root-owned Linux engine gives you the exact one-line command instead. A remote
tcp://endpoint is named so you start it where it runs. NodockerCLI at all? An interactive, consent-first install offer (macOS picks Desktop / OrbStack / Colima via Homebrew's official packages, Linux runs Docker's official script under sudo only after you say yes, WSL2 uses winget), with the exact command always shown and No as the default. Interactive terminals only, so scripts and CI never pop a GUI or an installer.CLEAT_NO_AUTOSTART=1turns all of it off. - Root-only hosts work. On a stock VPS image (or under
sudo) the box user maps to uid 0. Claude Code itself refuses--dangerously-skip-permissionsas root, which used to end every session with "Claude exited with code 1". Cleat now setsIS_SANDBOX=1(Claude Code's own escape hatch for sandboxed containers) on each session for root hosts only, so sessions launch normally and existing boxes heal on their next attach. - Enabling a cap recreates the container. Mounts are fixed at create time, so the next
cleatafter a config change prompts to recreate. Sessions survive via--continue. - Only a real config change prompts a recreate. Resizing your Docker VM or upgrading the CLI never does. The recreate prompt fires only when a capability, an environment key, or a configured
[resources]limit actually differs from how the box was created, never because a value Cleat derives from the VM moved underneath you. - A box from before the read-only
~/.claudemasks says so. Bind mounts are baked at create, so an older box keeps those surfaces writable until recreated. Every start notes it until you runcleat rm && cleat. - The VM size Cleat shows matches your slider.
docker inforeports the guest kernel's memory, which sits under the Docker Desktop slider by a reserve that grows with VM size (a 24 GB VM reads ~23.4 GiB), so Cleat reads the slider value straight from the Docker Desktop settings (the same way it reads swap), falling back to rounding the kernel's memory when that can't be read. The display and the undersized check share that one value everywhere (the advisory, the tuned nod,cleat status), so a correctly sized VM is never falsely flagged as too small and the number you see is the slider you set. - Ad-hoc installs survive stop and reboot. Per-container state lives under
~/.config/cleat/run, not/tmp, so macOS file rotation can't trigger a surprise recreate that wipes what you installed inside the box. - An ad-hoc install still dies on recreate.
sudo apt-get installsurvivesstopandstartjust fine, but config drift, an image refresh or a stale-mount auto-recreate all start the box from the base image, wiping it. A[setup]section is the durable path: approve it once and it re-runs automatically after every recreate. - An
sshbox survives a reboot. macOS regenerates your SSH agent socket path on every restart, so the path baked into the box goes stale. Cleat spots the vanished mount before starting and recreates the box transparently. No crypticmount … not a directoryerror, no lost session. - Trust and MCP approvals are per-project. Each project keeps its own isolated
.claude.json, which fixed the old corruption crash, but approvals no longer carry across repos. Approve once per project. Your login is unaffected. - Use
host.docker.internal, notlocalhost, to reach a service on your host (an MCP server, a local proxy). Inside the container,localhostis the container. On Linux, Cleat wires uphost.docker.internalfor you automatically. - With the
dockercap on long sessions, a Docker Desktop restart can renumber the socket group. Cleat re-heals access on every attach. If the socket itself was replaced, it tells you to runcleat stop && cleat resumerather than killing your live session. - Prompts treat redirected stdin as a decline. Every
[Y/n]read from/dev/nullor a wrapper means no, never a silent yes. Update checks are throttled and never downgrade. - Switching install method loses nothing. Config, trust, boxes, fork workspaces and Claude history all live outside the install itself (
~/.config/cleat,~/.claude, your project's own.cleat). Container names are derived from the project path rather than from where Cleat is installed. A box you made under one install is the same box under the other. Remove the one you have, add the other, carry on. - One cleat per machine. Cleat's own installers, the curl script and
cleat install, refuse to create a second one. Homebrew installs its keg without that check, so abrew installcan sit next to a script install, whichcleat statusflags when it finds more than one. Two cleats means a barecleatresolves to whichever comes first on yourPATH. The loser stays invisible until it bites: a stale version that "fixes itself" after a shell restart, an update that appears to do nothing. Re-installing over the path you already have is fine, that is just an upgrade. A different path is refused, though--forcereplaces it. A Homebrew keg is never replaced even with--force, because that would leave brew tracking an install it no longer owns. The scan looks at everyPATHentry plus the usual install directories even when they are offPATH, since a Homebrew prefix is invisible to a shell that never ranbrew shellenv.cleat statusnames them all when it finds more than one. Every interactive start also warns about the split, silenced withCLEAT_NO_INSTALL_CHECK=1. - A Homebrew install is brew's to manage. If
cleatis running from a Homebrew keg,cleat updaterunsbrew upgrade cleatdev/tap/cleatfor you andcleat uninstalloffersbrew uninstall, asking first because that removes the whole keg where the command normally just drops a symlink.cleat installrefuses outright, since brew already linked it. The on-start upgrade offer works the same as on a script install: same throttle, same "don't ask me again", same session continuing afterwards, just applied withbrew upgradeinstead of a checkout. Otherwisecleat updatewould fall through to "re-install with curl", which symlinks over/usr/local/bin/cleat. That path is Homebrew's own bin on an Intel Mac, so you would end up on a forked copy while brew still tracked an orphaned keg. Cleat decides by where the resolved binary physically lives, never by the directory you invoked it through. Detection never shells out tobrew, so it still holds wherebrewis off yourPATH. There the command is printed for you to run. - Cloud CLI caps were removed.
az,awsandgcloudare gone. Install the CLI on your host and pass credentials through theenvcap instead. Old aliases like--cap awsnow fail with "Unknown capability" rather than silently doing nothing.
Reference #
Commands#
Daily loop
cleat Start the sandbox and launch Claude for the default box. The default command.
cleat start [box] Build, start and launch Claude, the all-in-one. Same as bare cleat, for a named box. Add --fork to give it its own copy of the project.
cleat run [box] Bring the container up without attaching Claude, for scripting, CI, or pre-warming a box.
cleat resume [box] Resume the last session with --continue. Alias: r.
cleat stop [box] Stop the container and keep it for resume.
cleat rm [box] Remove the container. Host sessions are preserved. Resume recreates it. A fork box keeps its workspace copy, which may hold the only version of the work.
cleat fork start [box] Create a fork box and launch Claude in it. The same command as cleat start [box] --fork.
cleat fork run [box] Create a fork box without launching Claude.
cleat fork [sub] Manage workspace copies: bare lists them with size and age, then path, rm, prune and refresh.
cleat shell [box] Open a bash shell in the container. Alias: sh.
cleat claude [box] Launch Claude in a running container. Fresh session, recreates on config drift.
cleat login [box] Run claude login with the browser + OAuth callback bridge.
Inspect
cleat ps List every Cleat container across all projects.
cleat status Show this project's container, image, auth, capabilities and boxes, plus a warning when more than one cleat is installed on the machine. Alias: st.
cleat logs [box] Follow container logs (docker logs -f).
cleat describe [box] [text] Show or set a box's one-line description. Never recreates the container.
Capabilities & trust
cleat config [box] Open the .cleat editor (capabilities + resources), or use --enable / --disable / --memory / --cpus / --list. A box name edits that box's [box.<name>.*] sections inside the same .cleat, never a separate file. The global-scope editor can also generate a project .cleat for you.
cleat trust [path] [box] Approve a project's (or a box's) .cleat capabilities and [setup]. A lone box name trusts that box. --list to review approvals.
cleat untrust [path] [box] Remove a project's (or a box's) trust approval.
cleat setup [box] Run this project's [setup] provisioning now, or retry it after a failure.
cleat setup [box] --show Preview the setup payload plus its trust and marker state. Runs nothing.
Kits
cleat kit Interactive picker, like cleat config: pick a kit (a detail pane shows what it does), then set each agent model with the arrow keys, enter to enable.
cleat kit list Plain kit library and this project's per-box selections.
cleat kit <name> [box] Enable a curated Claude pre-configuration for a box. Merges on top of your own config. Takes effect next session (one exception: a pre-kits box is offered a one-time rebuild).
cleat kit off [box] Disable the box's kit. Back to your own config next session.
cleat kit show <name> Print every line a kit injects, before you enable it.
Image & version
cleat build Pull or build the image if it is missing.
cleat rebuild Rebuild the image from scratch (--no-cache).
cleat upgrade-claude [channel|version] Upgrade the bundled Claude Code in place. Default channel: latest.
cleat update Update the CLI to the latest tag and pull the matching image. On a Homebrew keg it runs brew upgrade instead.
cleat version Print the version. Aliases: -v, --version.
Setup
cleat install [--force] Symlink the cleat script onto your PATH (/usr/local/bin), for manual or cloned installs. Refused when another cleat is already installed anywhere on the machine. --force replaces it, except a Homebrew keg.
cleat uninstall Remove the cleat symlink from your PATH. On a Homebrew keg it offers brew uninstall instead, asking first. Docker state is left alone (see nuke for that).
Teardown
cleat stop-all Stop and remove every Cleat container.
cleat storage Read-only breakdown of Docker disk use: your images and boxes vs the shared build cache vs other projects, with a fill bar and reclaim levers.
cleat prune Remove stale cleat images: superseded builds and pulls. Boxes and other projects' images are never touched.
cleat prune --cache Also clear the shared Docker build cache (regenerable, used by all projects). A typed flag with a default-No confirm. Add --yes for scripts.
cleat clean Remove all Cleat containers and the image.
cleat nuke Remove all Cleat containers and images, plus the shared build cache. Asks you to type 'nuke' to confirm.
Flags#
--cap <name> Enable a capability for this run only: git, ssh, env, gh, hooks, docker.
--env KEY=VALUE Set an env var in the container. Works even without the env cap.
--env KEY Pass a host env var through by name.
--env-file PATH Load env vars from a file.
--project With config: write to ./.cleat instead of the global config.
--enable / --disable <cap> With config: toggle a capability non-interactively.
--memory <val> With config: set the box memory ceiling (e.g. 4g). default clears it. A ceiling that reaches your whole Docker VM is allowed and prints the full warning. A project .cleat is written as typed but capped at 8g at run time.
--cpus <val> With config: set the box CPU limit (e.g. 2). all clears it. Warns when the value exceeds the cores Docker reports.
--list With config: print enabled capabilities and resource limits. With a box name, each value is marked declared or inherited.
--fork Give the box its own copy of the project instead of the live tree. Only does something at create time: a box's workspace is fixed when the container is made.
--trust-project Approve this project's .cleat for this run and record it.
--trust-setup Approve this project's [setup] provisioning for this run and record it.
--desc "<text>" Set a box's description when it is created.
Configuration files#
~/.config/cleat/config Global capabilities, [resources] limits, [kits] agent-model overrides and [fork] dir. Trusted, because you wrote it.
~/.config/cleat/env Global env vars (loaded when the env cap is on).
<project>/.cleat Project capabilities, [resources] limits, [setup], [fork] exclude and every per-box section. cleat config can generate it for you. Capabilities require a trust approval before they apply.
<project>/.cleat.env Project env vars.
[box.<name>.<kind>] Per-box config, as sections of that one .cleat. <kind> is caps, resources, setup or fork. A declared section replaces the bare one. An absent section inherits it. Project-only.
<project>/.cleat.<box>.env Per-box env vars, the one per-box setting that is still its own file. Falls back to .cleat.env.
~/.config/cleat/forks/ Fork workspace copies, one per fork box. The default root, moved by [fork] dir.
~/.config/cleat/trust Recorded trust approvals (mode 0600).
Environment variables#
CLEAT_TRUST_PROJECT=1 Approve this project's .cleat capabilities non-interactively (for CI / scripts).
CLEAT_TRUST_SETUP=1 Approve this project's [setup] provisioning non-interactively (for CI / scripts). CLEAT_TRUST_PROJECT does not cover it.
CLEAT_CLAUDE_CHANNEL Claude Code update channel: stable, latest (default), or a pinned version.
CLEAT_NO_UPDATE_CHECK=1 Disable the CLI self-update check (no network call to GitHub on start).
CLEAT_NO_CLAUDE_UPDATE_CHECK=1 Disable the Claude Code update check (no network call on start).
CLEAT_BROWSER_BRIDGE Browser-bridge open policy: auto (default: defer plain links to the terminal so a click opens one tab, not two), always (open every URL via the bridge), or off (never auto-open, though the login callback proxy still runs).
CLEAT_NO_CLIPBOARD_IMAGE=1 Turn off native image paste. The box's ctrl+v stops pulling the host clipboard image and behaves as it did before v1.4.0, where a paste inside a box finds no image. Copying out of the box is unaffected.
CLEAT_NO_IDLE_SWEEP=1 Disable the on-start idle-session sweep (which stops detached boxes that no longer have an agent running, to free Docker VM memory).
CLEAT_NO_DOCKER_GATE=1 Skip the hold on start when the Docker VM is undersized or swap is left at the default (the advisory still prints). The gate is interactive-only, so this only matters for a terminal that has accepted its Docker setup.
CLEAT_NO_DISK_GATE=1 Skip the hold on start when the shared Docker disk is nearly full (the banner still prints). Interactive-only. Separate from CLEAT_NO_DOCKER_GATE.
CLEAT_IDLE_GRACE_MINS Minutes a box must be detached and idle before the sweep may stop it (default 30).
CLEAT_NO_AUTOSTART=1 Disable Docker autopilot (session verbs normally auto-start a down Docker Desktop / OrbStack / Colima and wait, but with this set, cleat just prints the exact start (or install) command and exits).
CLEAT_NO_INSTALL_CHECK=1 Silence the on-start warning when more than one cleat install is found on the machine. cleat status still shows the full install list on demand.
CLEAT_AUTOSTART_TIMEOUT_SECS How long autopilot waits for the started daemon before giving up (default 90, sized for a Docker Desktop cold boot).
All ~/.config/cleat paths honor $XDG_CONFIG_HOME if it is set.
Full release history is on the changelog. The source lives on GitHub.
Get started
Drop into any project and type cleat.