Cleat
September 25, 2026 · View on GitHub
Give the agent a cage, not your keys.
Unattended, not unguarded.
Run AI coding agents with full autonomous permissions, safely sandboxed in Docker.
One command. Per-project isolation. Your host stays untouched.
A real session, not a mockup: Claude Code hunts for keys, finds nothing, then rm -rf's the box's own OS. The host doesn't notice.
curl -fsSL https://cleat.sh/install | bash
cd ~/your-project && cleat
That's it. First run pulls the prebuilt image from GHCR (~30s), starts an isolated container for your project and drops you into Claude Code with full permissions, all sandboxed. If the prebuilt image is unavailable for your CLI version, it falls back to a local build (~2 min) automatically.
┌─────────────────────┐ ┌─────────────────────────────────┐
│ Your machine │ │ Docker container │
│ │ │ │
│ ~/my-project ───────────> │ /workspace │
│ ~/.claude ──────────────> │ /home/coder/.claude │
│ │ │ │
│ Everything else │ │ Claude Code runs free here: │
│ is untouched. │ │ install, build, delete, run │
│ │ │ anything. Fully sandboxed. │
└─────────────────────┘ └─────────────────────────────────┘
Stay updated: Watch → Custom → Releases on this repo and upgrade any time with cleat update.
Requirements
- Docker -- must be installed and running
- macOS or Linux (Windows support via WSL2)
- An Anthropic account -- Pro, Max, Team, or Enterprise plan, or an API key
- git -- used by the installer
Compatibility
Cleat is a single bash script plus a Docker image. It runs anywhere Docker and bash do. Every axis below is covered by the test suite. Most of them run on real hardware in CI on every push.
| Axis | Runs on |
|---|---|
| Host OS | macOS (Apple Silicon + Intel) · Linux · Windows via WSL2 |
| Shell | bash 3.2 (the macOS default) through bash 5 |
| Coreutils | BSD (macOS) · GNU (Linux) · GNU-on-macOS (Homebrew coreutils) |
| Docker engine | Docker Desktop · OrbStack · Colima · Rancher Desktop · Lima · native Linux rootful + rootless |
| Architecture | arm64 · amd64 |
Cleat needs a daemon it can reach on the local machine, because a box mounts your project by host path. A remote daemon over tcp:// or ssh:// is not a supported target.
The docker capability is engine-aware: it binds the right daemon socket for where the daemon actually runs, so a caged agent builds and runs containers on your engine whether that daemon lives in a VM (Docker Desktop, OrbStack, Colima, Lima), on the host, or rootless under /run/user.
Why Cleat?
The problem
Claude Code with --dangerously-skip-permissions is the fastest way to build software with AI. No confirmation dialogs, no permission prompts. Claude just does what you ask. But on your actual machine, that means:
- System files and configs can be modified or deleted
- Packages can be installed, upgraded, or removed system-wide
- Dotfiles, SSH keys, or credentials can be read or overwritten
- Other projects on your machine can be accessed or changed
- A single bad command can irreversibly delete your work -- on a Mac's internal SSD, TRIM means there is no undo and no undelete, only your last backup -- or leak a live credential to the internet, where it is abused in minutes
The solution
Cleat gives you the best of both worlds:
| Without isolation | With Cleat | |
|---|---|---|
| Claude can edit project files | Yes | Yes |
| Claude can install packages | Yes (on your system) | Yes (in container) |
| Claude can run any command | Yes (on your system) | Yes (in container) |
| Claude can access other projects | Yes | No |
| Claude can modify your system | Yes | No |
| Claude can read ~/.ssh, credentials | Yes | Opt-in (via cleat config) |
| Safe to leave running overnight | No | Yes |
| File ownership issues | N/A | None (UID/GID mapped) |
| Copy to host clipboard | Yes | Yes (via clipboard bridge) |
Key features
- One command --
cleatpulls (or builds) the image, starts a container and launches Claude Code - Per-project isolation -- each project gets its own container, run multiple projects in parallel
- Session persistence -- stop and resume sessions without losing context, each project's history is isolated
- Safe for unattended use -- let Claude work overnight without risking your system
- Zero file permission issues -- container user matches your host UID/GID automatically
- Shared auth -- log in once and every box uses the same credentials, unless you pin a box to a named login with
cleat account - Clipboard support --
pbcopy,xclipandxselshims route to your host clipboard via a file bridge -- no X11 or special terminal features needed - Image paste (ctrl+v) -- paste a screenshot from your host clipboard straight into Claude Code inside a box. No install, no capability, images only (clipboard text never crosses this channel)
- Lightweight -- Node.js-based image with Python, Git, GitHub CLI, jq and socat
- Capabilities -- opt-in access to host git identity (
--cap git), SSH keys (--cap ssh), env var passthrough (--cap env), host hook execution (--cap hooks), GitHub CLI auth (--cap gh), host Docker daemon for testing dockerized apps (--cap docker) and a guard that answers Claude Code's dangerous-rm prompt (--cap unsafe-rm). All disabled by default - Pre-built image --
cleat startpulls fromghcr.io/cleatdev/cleat(~30s) instead of building locally (~2-5 min), with automatic local-build fallback - Forked workspaces --
--forkgives a box its own copy of the project, so several agents can work in parallel without touching your tree - Contained Claude home -- a box sees only its own project's Claude history. The instruction surfaces your host
claudeobeys are read-only inside the cage - Account switching --
cleat accountkeeps two or more Claude logins under names and pins a box to one, so hitting the five-hour limit on one Max account is one command instead of a browser login. Conversations and project history are shared across the switch - Session management --
cleat sessionlists a box's Claude conversations with their real sizes, deletes the ones you are done with (which the Claude Code CLI itself cannot do for a single conversation) and keeps them restorable in a trash for 30 days - Hook execution on host -- the hooks in your
~/.claude/settings.jsonrun on the host, not in the container. Hooks a project defines never run there. The event queue the box writes is bounded at every session start and while a hook bridge runs - Browser bridge --
openandxdg-openinside the container forward URLs to your host browser. Cleat checks the origin first, so the box cannot choose where your logged-in browser goes - Host connectivity --
host.docker.internalalways available, user-defined hooks and MCP servers work out of the box - Configuration drift detection -- notifies when config has changed since container creation
- Clean terminal output -- braille spinners for slow operations, suppressed Docker noise, canonical startup/exit sequences
- Auto-upgrade notifications -- checks for updates every 10 minutes and notifies you before launching Claude
- Release highlights -- a short, non-blocking note on the first few runs after an update tells you the new version's headline feature
The story behind this
I was deep into vibe coding, letting Claude Code run with --dangerously-skip-permissions so it could ship without interrupting my flow. Kick off a task, step away, come back to working code. Multiple projects on my Mac, sometimes left running overnight through a big refactor.
The campfire version of the story is that one night it went rogue and bricked my Mac. The honest version is scarier, because it actually happens. The hardware was never in danger -- Apple sealed the OS so thoroughly that not even root can modify /System in place. Any Mac you can boot into Internet Recovery or DFU you can bring back. What an agent running with no gates can actually destroy is everything that isn't the OS. It runs as you.
So it installs packages system-wide and litters configs across your home directory until the machine you keep clean is quietly rotting. It reads ~/.ssh, ~/.aws, your .npmrc tokens and your .env files -- and it can commit or deploy those secrets straight to the internet. That one isn't hypothetical for us: one night an agent wired up a deploy and an API key rode along into a public static deployment. Nothing was "hacked" -- it was running as us, it had the key, it shipped it. Public keys get scraped and abused in minutes, not days. Providers rarely refund fraud on technically-valid requests. By the time we rotated it, roughly $10,000 was already gone.
And one bad glob ends the rest: developers have wiped entire home folders with a trailing ~/ on an rm -rf, where the shell expands the tilde to your whole home directory after the agent's own check passes. On a Mac's internal SSD, TRIM is on by default, so the freed blocks are discarded and the per-file encryption key is destroyed within seconds. No undo. No undelete. Only your last backup, if you had one. Gemini CLI destroyed a user's project files the same way. Replit's agent dropped a production database. These are not hypotheticals -- they are documented and all within the last year. Data and credentials die. The hardware survives them.
You can defend the host by hand: never run --dangerously-skip-permissions on your machine, never give the agent passwordless sudo, never pre-approve broad globs like Bash(sudo *) or Bash(*) and keep real backups. That is a lot of discipline to maintain on every project, forever, at 2am.
So I built Cleat. Same unrestricted power, but inside a per-project Docker sandbox where, by default, the blast radius stops at the container. Your host system stays untouched. Capabilities -- ssh, git, env, gh, docker -- are all off until you opt in. Claude can rm -rf / inside the container and the rest of your Mac won't even notice. Give the agent a cage, not your keys.
We haven't leaked a key, lost a home folder, or restored from a backup since.
Install
Quick install (recommended)
curl -fsSL https://cleat.sh/install | bash
This clones the repo to ~/.cleat, checks out the latest stable release tag and symlinks cleat into your PATH. The short URL resolves to the same install.sh served from the latest tagged release on GitHub.
Homebrew
brew install cleatdev/tap/cleat
The fully qualified name is required, since a bare brew tap no longer grants trust as of Homebrew 6.0.0. On a keg, cleat update runs brew upgrade and cleat uninstall offers brew uninstall. Cleat's own state lives outside the install, so switching between the script and Homebrew loses nothing.
Dev install (from local clone)
git clone https://github.com/cleatdev/cleat.git
cd cleat
./install.sh --local
This symlinks your working copy into PATH. Edits to bin/cleat take effect immediately, no reinstall needed. Switch back to the official release at any time with ./install.sh (without --local).
Update
Releases are published as git tags (e.g. v0.1.0). The updater fetches tags and checks out the latest one:
cleat update
To update just the Claude Code build bundled in the image (without a full rebuild):
cleat upgrade-claude # latest (default)
cleat upgrade-claude stable # stable channel
cleat upgrade-claude 2.1.156 # pin a version
This re-runs the official installer in the image and commits it back, then offers to recreate the current project's container so the new version takes effect immediately. The change is local-only. cleat rebuild/update/nuke reset the image to a fresh release build (which already bundles a current Claude Code).
You don't have to remember to run it: when you start cleat interactively, it checks (at most once every 10 minutes) whether a newer Claude Code is out and offers to upgrade before starting. The check is skipped for non-interactive runs, never blocks on a slow network, defaults to the latest channel (CLEAT_CLAUDE_CHANNEL=stable to change it) and can be turned off with CLEAT_NO_CLAUDE_UPDATE_CHECK=1.
To rebuild the whole image from scratch instead:
cleat rebuild
Getting started
1. Authenticate (first time only)
cd ~/your-project
cleat # starts the container + launches Claude
# Claude will prompt you to log in on first run
Or authenticate separately:
cleat start # start the container
cleat login # opens a browser URL to sign in
Credentials are saved to ~/.claude on your host and shared across all containers automatically. Log in once, every container picks it up: whether you signed in on the host or inside an unpinned box, the next box you start or create carries the login. A login inside a box pinned with cleat account is saved to that account instead. On macOS, where Claude keeps its login in the Keychain rather than a file, Cleat bridges that token into the box for you on launch.
2. Use it
cd ~/your-project
cleat
That's it. You're inside Claude Code with full autonomous permissions, sandboxed in Docker.
Usage
Daily workflow
# Start a new session
cd ~/my-project
cleat
# Resume your last session
cleat resume
# Check what's running
cleat ps
# Stop when done (keeps container for resume)
cleat stop
# Remove the container when you want a fresh environment.
# Session history lives on the host at ~/.claude/projects/<key>/
# and is NOT touched by cleat rm. `cleat resume` after rm
# auto-creates a fresh container and picks up where you left off.
cleat rm
cleat resume
Multiple projects at once
Each project gets its own isolated container:
# Terminal 1
cd ~/backend && cleat
# Terminal 2
cd ~/frontend && cleat
# See all running containers
cleat ps
Cleat containers:
● cleat-backend-1a2b3c4d
Up 12 minutes
/Users/you/backend
● cleat-frontend-5e6f7a8b
Up 3 minutes
/Users/you/frontend
Boxes: multiple sandboxes per project
A box is a named, isolated container scoped to the current directory. By
default every box mounts the same live files (a fork box is the exception,
see below), but each has its own capabilities, writable
layer and Claude session, so a locked-down dev box can run beside a
cloud-capable az box over the same repo. The agent in dev can't reach the
Docker socket or cloud token that az holds.
cleat start # the default box (main)
cleat start az --desc "cloud box" # a separate az sandbox
cleat config az --enable docker # give just the az box the docker cap
cleat resume dev # resume the dev box's last session
cleat status # list this project's boxes
The token after a verb is a box name (lowercase letters, digits, -, _),
never a path. cleat always operates on the current directory. The default box
is byte-identical to the pre-boxes container, so existing projects keep working
unchanged. Per-box config lives in [box.<name>.<kind>] sections of the one
project .cleat. A declared section replaces the project default rather than
merging it, so a box can hold fewer caps than the project. See Per-box
capabilities below. One caveat: ~/.claude (your Anthropic auth) is shared
across boxes. A box isolates host capabilities and the writable layer, not your
Claude login.
Forked workspaces
A box normally shares your live project directory. --fork gives it its own
copy, so an agent can work without touching your tree.
cleat start feat-a --fork # its own copy of the project
cleat start feat-b --fork # another one, independent
Run it a few times and you have several agents on the same project, each in its
own container working on its own files. They still share what every box shares:
your Claude login (unless a box is pinned with cleat account) and the host
~/.claude/plugins.
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 silently ignored, with cleat rm <box> as the
remedy. Forking a fork, or pointing the fork root inside the project so the copy
would contain itself, is refused too.
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
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. Exclude what you do not want with
[fork] exclude = node_modules in .cleat ([box.<name>.fork] to scope the
excludes to one box). 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.
The launch summary names the copy and how old it is, so a stale fork is never silent:
Fork: ~/.config/cleat/forks/cleat-myproj-2f96c884-feat-a (copied 3h ago)
Move the fork root with [fork] dir in your global config
(~/.config/cleat/config) if your projects live on another volume: copy-on-write
only works within a volume. It is read from the global config only and must be
an absolute path. A [fork] dir in a project's .cleat is ignored on purpose,
because .cleat arrives with a cloned repo and this value is a path Cleat
creates and deletes under.
cleat rm <box> frees the container and keeps the copy, since 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.
Worth knowing before you rely on it:
- The copy is a point-in-time snapshot. A fork taken an hour ago does not have work you did in the live tree since.
- Every running box that can write the project folder (the project's own boxes plus any box on a folder above or below it) is paused while the copy runs and resumed right after. Nothing can swap a folder for a link mid-copy. An attached session in one of those boxes freezes for the copy and one line names what was paused. If a box cannot be paused the fork is refused and nothing is copied.
- Without copy-on-write (Linux without reflink support, or a fork root on a different volume) the copy is real duplicated disk.
cleat storagedoes not see fork copies. It measures the Docker store, while the copies live on your filesystem.- Landing the work is yours. Cleat copies out, it does not merge back.
The copies outlive their boxes on purpose, so they get their own verb. fork is
a verb here while --fork stays a flag on start and run.
cleat fork start feat-a # create a fork box and launch Claude (= start feat-a --fork)
cleat fork run feat-a # create it without launching Claude
cleat fork # every copy: apparent size, age, is its box still there
cleat fork path feat-a # bare path, so cd "$(cleat fork path feat-a)" works
cleat fork rm feat-a # delete one copy and drop the box's fork marker
cleat fork prune # delete copies whose container is gone, plus any stale marker
cleat fork refresh feat-a # replace a copy with a fresh one from the project
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.
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.
Kits: curated Claude pre-configurations, per box
A kit is a curated Claude Code setup (a CLAUDE.md policy plus custom
subagents) that you enable for one box with one command. The flagship kit,
plan-big-execute-small, adapts the coordinator pattern from
Anthropic's cookbook
(big models for planning, small models for execution):
run your session on Fable 5.1 (set once with /model inside the session) and
it plans and reviews while worker and scout subagents (Sonnet 5 by
default) execute and explore, each in its own context window so the main
session stays lean. Flagship judgment on the plan
and every review, the mechanical bulk billed at the worker model's rate, so
heavy work burns your rate limit far slower. Prefer different economics? Pin or swap the agent
models under a [kits] section in ~/.config/cleat/config
(worker_model = haiku). The planner is always your session's model.
cleat kit # interactive picker: kit, then models
cleat kit list # plain library + this project's selections
cleat kit plan-big-execute-small # or enable directly, for the main box
cleat kit off # back to your own config next session
cleat kit show plan-big-execute-small # read every line it injects, first
A kit merges on top of your own config inside the box, so your global
CLAUDE.md and agents keep working: the kit section is appended and clearly
marked, after a Cleat box notes section every box carries with the
clipboard-bridge rules. Its content stays off the host: kits live in generated
mask files mounted into the box, nothing kit-related lands in your ~/.claude
and native claude never sees them. (Creating a box does seed inert placeholders there
when missing: an empty CLAUDE.md, empty directories for every masked or per-box path,
an empty loop.md, {"bindings":[]} for keybindings.json and {} for the other
masked JSON files. Mount targets, not content.) Different boxes can run different kits on the
same repo. Kits contain instructions and subagents only, no hooks and no settings.
Whatever they steer the agent to do happens inside the cage. As a
hardening side effect, your five user-level instruction surfaces
(~/.claude/CLAUDE.md, agents, commands, skills and plugins) are
mounted read-only in every box: the agent reads them but can't plant a
host-user-level command, agent or skill that your host claude would later
obey. skills matters most, because Claude Code loads whatever it finds there
on its own and can invoke it without you typing anything. Author those at
project level (.claude/agents/, .claude/commands/, .claude/skills/)
instead. Plugins you already have stay readable and usable in a box, but installing a
new one from inside a box fails, because that directory is read-only. Install
plugins on the host and every box sees them. Those read-only copies dereference
a top-level symlink (a dotfile-repo commands dir shows up as real files in the
box) but keep a symlink nested inside a skill as a link, so a skill pointing at
~/.ssh cannot pull real key bytes into the box. Other projects stay invisible: ~/.claude/projects holds a full transcript of
every project you have ever run Claude Code on, so a box gets a generated
directory containing only its own project's sessions. Its own session stays
writable, so --continue and --resume work normally. file-history,
paste-cache, uploads, backups, shell-snapshots, session-env, daemon,
seed-admin, sessions, tasks, jobs and hooks each become an empty
per-box directory, created without group or other write. hooks is the one
that matters most. It is also why these are generated empties rather than
read-only views. 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 to
authenticate. Fifteen more instruction surfaces at the root of ~/.claude
(rules/, workflows/, output-styles/, themes/, keybindings.json,
loop.md, settings.local.json and eight others) are read-only in a box too.
Six of them show your own content, copied fresh on every create, start and
resume. That copy is stricter: it takes directories and regular files only and
follows no symlink at all, not even a top-level one, so a dotfile-repo symlink at
one of those names is not seen in a box. The other nine are empty. A broken
symlink at one of the mask paths stops box create with a
clear fix-or-remove error (your symlink is never deleted) and a box created
before these masks existed prints a recreate note on every start with the
command for that box: cleat rm && cleat for the default box,
cleat rm <box> && cleat start <box> for a named one.
Arrow-up prompt history is per project too. It lives in
~/.config/cleat/history/<key>/, outside every folder a box can see, so a box
can add lines to it but can never swap the file for a link to one of yours. A
history file an older release kept in the session folder is carried over once.
A box created before this move is recreated once, with no prompt, the next time
Cleat starts or resumes it: its old history mount cannot be changed any other
way. The recreate keeps conversations, logins and trust, then runs [setup]
again. Anything you installed inside the box outside [setup] is gone.
Accounts: two Claude logins, one command to switch
A Claude Max account has a five-hour window. With two of them the only way to move between them is /login, in a browser, both directions, every time one runs out. cleat account gives each login a name and pins a box to one of them.
cleat account work2 # pin this box to a login called work2
# start the box and run /login once (or run cleat login). That login is remembered under the name
cleat account # picker: switch, rename or remove, → for the trash
cleat account work1 # back to the first one, no browser
cleat account default # unpin: back to your shared ~/.claude login
Only the login moves. Conversations, project history and settings are identical on both accounts, by construction rather than by copying: a switch relocates Claude Code's credential store for that box and nothing else. So you can hit a limit mid-conversation, switch, then carry on in the same conversation.
Switch a box that has a Claude session running and the command hands it over. Cleat first shows what the handover costs: anything typed there but not sent, any background agent or monitor the box runs and a first reply on the new account that rereads the whole conversation without a prompt cache. It asks Hand over?, stops the session, moves the login and lets that terminal reopen the same conversation on the new account. --yes skips the question. --now also restarts a session that is mid-turn or waiting for an answer. A reply in progress is lost and a pending question closes unanswered. The conversation reopens straight into the chat with no resume question in the way, so you type continue there. A box running the amd64 image under Rosetta on Apple Silicon is judged the same way as a native one. When the box runs a Claude Code version the handover has not been driven end to end against, Cleat names that version before it asks.
The pin is per box, because the limit is per account and you probably have several boxes open. One can move to the fresh account while the others keep draining the first. The picker's first row is always your shared ~/.claude login, so a pin is never a one-way door.
Removing a login is never an unlink: it goes to a trash kept for 30 days. → in the picker crosses to it and ← comes back, the same two keys cleat session uses. Each row still says whose login it was and when it went. ⏎ puts it back. Remove your last account and the picker opens the trash rather than dropping you at a shell prompt, because that is the only place it can be got back from.
A login a box had that no account could save is kept too, not deleted. It goes to a held area for 30 days, on the same clock as the trash. Cleat holds one when it cannot prove a refreshed login belongs to its account, when a /login inside the box signed in as a different account or when a login is left behind in a box you removed. cleat account held lists them with where each came from and why. cleat account adopt <id> <name> saves one as an account. A new name creates that account. An existing name takes the login in and holds that account's previous login in its place.
A box created before this feature has no credential mount. Cleat says so and tells you to recreate it once with cleat rm <box>, rather than half working: without the mount, /login would write the store inside the container where cleat rm destroys it. Until you do, cleat status and the launch summary both call the pin not in effect rather than naming an account the box is not using. The recreate keeps your conversations, trust and env, which live on the host. It does not keep what you installed inside the box, so move that into a [setup] section first.
Upgrading to a build that has this feature does nothing by itself: no image rebuild, no container recreate. A box picks up its credential mount the next time it is recreated for its own reasons.
Claude Code refreshes its own token about every eight hours, inside the box. Every attach stages the stored login in and every detach takes the refreshed one back out, newest wins. cleat rm, every recreate and cleat nuke do that before they touch a run directory, so a refresh never sends you back to a browser. A /login run in a cleat shell, or cleat login itself, is saved to the pinned account the same way.
Logins live in ~/.config/cleat/accounts, not in ~/.claude, so no box can read the account store. Only the pinned account's login is staged into a pinned box. Your shared login in ~/.claude is still mounted into every box, so a pinned box can also read that one when it exists.
The list shows usage when it can back it up: live while that account's own access token is alive, a timestamped snapshot when it is not. Once a reset time has passed it says the window reset rather than showing a stale percentage. It never refreshes a parked login to draw a bar, because the refresh can rotate the token.
One thing a mid-conversation switch costs: Claude Code ties a conversation to the account that started it. Resuming one on the other account keeps the local transcript and --resume as they were. What stops is that conversation's server-side history being backfilled from the switch point. Cleat says so every time.
Sessions: list, rename and delete conversations
The Claude Code CLI can rename a conversation but not delete one. claude project purge takes a whole project at once. claude rm removes a background job and keeps its transcript. An automatic sweep deletes by age (30 days by default) with no off switch. Cleat already creates and owns the directory those transcripts live in, so it can work one at a time.
cleat session # list this project's conversations, with sizes
cleat session rm 1f204d6c # move one to the trash
cleat session trash # see what is in the trash
cleat session restore 1f204d6c # change your mind, within 30 days
cleat session rename 1f204d6c --title "site redesign"
Claude sessions (cleat)
/Users/you/.claude/projects/cleat-0f459ff8
▸ 20h ago 1007 MB 1f204d6c site-redesign
3d ago 91 MB aa9375b7 egress-audit
4d ago 248 MB 68975fe4 cli-work
27d ago 11 MB 49d8c600 growth-fable
4 sessions → trash (2)
up/down move enter rename or delete q close
On a terminal that list is a picker: up/down to move, enter to open Rename / Delete / Cancel, right to open the trash, q to leave. Piped or redirected it prints the rows and exits, so it doubles as the dry run for rm.
An action keeps you in the list. Rename one and it redraws with the new name on the row you were on. Delete one and it redraws without that row, with the cursor on whatever took its place, so clearing out a long history is one run rather than several. The receipt stays above the list.
The trash is the second view of the same list. right opens it and left comes back. It shows what you deleted, how long ago and how much it holds. enter puts one back. Delete the last conversation in a project and Cleat shows you the trash rather than dropping you at a prompt. cleat session trash prints the same list without a terminal.
The list fills the window, showing as many conversations as the terminal has room for or as many as you have, whichever is fewer. Resize the window and press any key and it reflows. On a pane too narrow to lay a row out, Cleat prints the plain list instead of a picker whose rows would wrap.
The size column is why the list is useful. A conversation is a transcript plus a sibling directory of subagent transcripts and tool results. That sidecar is usually several times bigger, so the sizes shown add both and a delete always takes both. They are apparent sizes, the same caveat cleat fork carries.
Deleting moves the conversation to a trash in ~/.config/cleat/session-trash/, outside every folder a box can see. It prints how to undo it. It refuses while the box has a live Claude session. It also refuses when Docker cannot tell it whether the box is running, because a transcript removed while Claude is writing to it is lost silently. Without a terminal it shows what would go and deletes nothing unless you add --yes.
Restoring resolves the id against the trash, so the short id the delete printed is the one that works. It never overwrites a conversation that has come back under the same name.
Only a real session id is ever touched. That directory also holds this project's Claude memory. Nothing in cleat session can reach it.
Listing works with Docker down, which is usually when you want it.
Command reference
Quick start
| Command | Description |
|---|---|
cleat | Build + run + launch Claude Code (all-in-one) |
cleat resume | Resume the most recent conversation not open in another terminal, keeping its 1M context (recreates the container if cleat rm was run since: sessions persist on the host) |
Lifecycle
| Command | Description |
|---|---|
cleat stop [box] | Stop this project's container (keeps it for resume) |
cleat rm [box] | Stop and remove container permanently (session history on the host is preserved) |
cleat stop-all | Stop all Cleat containers |
cleat build | Build the Docker image |
cleat rebuild | Force rebuild the image from scratch |
cleat upgrade-claude [stable|latest|VERSION] | Update the bundled Claude Code in place (default latest). Offers to recreate the current container |
cleat clean | Stop everything and remove the image |
cleat prune | Remove stale cleat images (boxes and other projects untouched) |
cleat prune --cache | Also clear the shared Docker build cache (regenerable, all projects). Typed flag + default-No confirm |
cleat storage | Read-only Docker disk breakdown: fill bar, cleat vs shared vs other projects |
cleat nuke | Remove all Cleat containers and images, plus the shared build cache |
Capabilities
| Command | Description |
|---|---|
cleat config | Open the .cleat editor: a keyboard TUI for capabilities + resources |
cleat config --list | List capabilities and resources (memory, cpus) and their status |
cleat config --enable <cap> | Enable a capability (e.g. git, ssh, env) |
cleat config --disable <cap> | Disable a capability |
cleat config --memory <val> | Set the box memory ceiling (default clears it) |
cleat config --cpus <val> | Set the box CPU limit (all clears it) |
cleat config --project --enable <cap> | Project-level config (saved to .cleat) |
cleat config <box> --enable <cap> | Per-box config (writes [box.<box>.caps] in .cleat, replacing the project set for that box) |
cleat config <box> --list | Per-box view: names the box and marks each value declared or inherited |
The editor also has a generate row (global scope): it stamps your current caps + resources into ./.cleat so a per-project config never has to be hand-written. It preserves the rest of the file (an existing [setup], any [box.*] sections) and does not auto-trust (the file goes through the normal trust prompt on the next run).
Workspace trust
| Command | Description |
|---|---|
cleat trust [path] [box] | Record approval for a project's (or a box's) .cleat capabilities, [setup] and env-file host variables |
cleat trust [box] | Trust a box of the current project (a lone valid box name) |
cleat trust --list | List trusted projects and boxes (yellow = config changed since approval) |
cleat untrust [path] [box] | Remove a project's (or a box's) trust entry |
Kits
| Command | Description |
|---|---|
cleat kit | Interactive picker: pick a kit, then its agent models (TUI, like cleat config) |
cleat kit list | Plain kit library and this project's selections |
cleat kit <name> [box] | Enable a kit for a box (merges on top of your config, next session) |
cleat kit off [box] | Disable the box's kit (back to your own config) |
cleat kit show <name> | Print everything a kit injects |
Setup
| Command | Description |
|---|---|
cleat setup [box] | Run this project's [setup] provisioning now (the box must already be running) |
cleat setup [box] --show | Preview the payload, trust state and marker state without running anything |
Flags (apply to start, run, resume, claude, shell, login)
| Flag | Description |
|---|---|
--cap <name> | Enable a capability for this session only |
--env KEY=VALUE | Pass environment variable to container |
--env KEY | Inherit from host environment |
--env-file PATH | Load env vars from file |
--trust-project | Auto-approve the current project's .cleat caps and env-file host variables without prompting |
--trust-setup | Auto-approve the current project's [setup] provisioning without prompting |
--desc <text> | Set the box's description at start (host-side, never recreates) |
--fork | Give the box its own copy of the project instead of the live tree (create time only) |
fork [sub] | Fork a box (start, run) or manage the copies (list, path, rm, prune, refresh) |
Browser
| Command | Description |
|---|---|
cleat browser origins | List the origins a box may ask your host to open, including any you added and any entry ignored. cleat browser alone does the same |
cleat browser allow <host> | Add one origin for every box. Stored under [browser] in ~/.config/cleat/config |
Interact
| Command | Description |
|---|---|
cleat claude [box] | Attach Claude Code to a running container |
cleat shell [box] | Open bash inside the container |
cleat login [box] | Sign in to Anthropic by running claude auth login. A pinned box saves the login to its account |
cleat logs [box] | Tail container logs |
Accounts
| Command | Description |
|---|---|
cleat account | Picker for this project's default box: switch, rename or remove a login |
cleat account <name> [box] | Pin a box to a named login, creating it if new. A live session in the box restarts on it (--yes skips the prompt, --now restarts mid-turn) |
cleat account default [box] | Unpin a box, back to your shared ~/.claude login |
cleat account list [box] | Plain list of stored logins, marking the pinned one |
cleat account rename <old> <new> | Rename a stored login. Every box pinned to it follows |
cleat account rm <name> | Move a login to the trash, with --yes to skip the prompt |
cleat account restore <name> | Bring a removed login back (30 days) |
cleat account trash | List removed logins. The picker reaches the same list with → |
cleat account held | List logins kept aside instead of deleted (30 days) |
cleat account adopt <id> <name> | Save a held login as an account (a new name creates it) |
Info
| Command | Description |
|---|---|
cleat status | Show this project's boxes, image and auth status |
cleat describe [box] [text] | Show or set a box's description (host-side, never recreates) |
cleat session [box] | List this box's Claude conversations with the disk each one really costs, then rename or delete one |
cleat session rename <id> [box] | Give a conversation a name, or --title <text> to skip the prompt |
cleat session rm <id> [box] | Move a conversation and its subagent data to the trash, with --yes |
cleat session trash [box] | List what has been deleted and not yet swept |
cleat session restore <id> [box] | Bring a trashed conversation back (30 days) |
cleat ps | List all Cleat containers (running and stopped, with a box column) |
cleat update | Check for updates and install the latest version (a Homebrew install runs brew upgrade instead, or prints it when brew is off PATH) |
cleat version | Show current version |
All commands operate on the current working directory. The optional [box] is a
named sandbox for the project (default: main). See Boxes above.
How it works
Architecture
┌──────────────────────────────────────────────────────────────┐
│ Your machine │
│ │
│ ~/.claude ──────────────┐ (auth, sessions, settings) │
│ ~/.claude.json ─────────┼── (config) │
│ ~/my-project ───────────┼──────────────────────┐ │
│ │ │ │
│ ┌────────────────────────┼──────────────────────┼───────┐ │
│ │ Docker container │ │ │ │
│ │ v v │ │
│ │ /home/coder/.claude /workspace │ │
│ │ /home/coder/.claude.json │ │
│ │ │ │
│ │ Claude Code (--dangerously-skip-permissions) │ │
│ │ │ │
│ │ Can: read/write project, install packages, run cmds │ │
│ │ Cannot: touch host system, access other projects │ │
│ └───────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Components
| File | Purpose |
|---|---|
bin/cleat | CLI script (symlinked as cleat) |
docker/Dockerfile | Node.js bookworm-slim image with Claude Code (native installer) |
docker/entrypoint.sh | Maps host UID/GID into the container so files are owned by you |
docker/clip | Clipboard shim -- writes to file bridge (primary) or OSC 52 daemon (fallback). Symlinked as pbcopy, xclip, xsel |
docker/clip-daemon | Background daemon -- relays clipboard data to the host terminal via OSC 52 (fallback for terminals that support it) |
docker/CLAUDE.md | User-level instructions for Claude Code (clipboard usage, paste limitations) |
install.sh | One-line installer (curl | bash) |
What happens when you run cleat
- Pulls or builds the Docker image (first run only) -- pulls pre-built image from registry (~30s), falls back to local build if unavailable. Image includes Node.js, Python, Git, GitHub CLI, jq, socat and Claude Code CLI
- Starts a container named
cleat-<dirname>-<hash>(hash derived from the full project path) with your project mounted at/workspace - Maps your UID/GID into the container so files created by Claude are owned by you on the host
- Mounts
~/.claudefor shared authentication across all containers - Starts the clipboard bridge -- a host-side watcher and a shared file mount so
pbcopy/xclip/xselrelay to your host clipboard - Launches Claude Code with
--dangerously-skip-permissionsinside the sandbox
Security hardening
Containers run with these protections by default:
--pids-limit 4096-- prevents fork bombs from affecting the host- A per-box memory ceiling (a quarter of your Docker VM's memory, clamped to 4-8 GB) with swap disabled -- a runaway process OOMs inside its own box instead of swap-thrashing every session at once. Set it with
cleat config(the arrow-key editor has a Resources group) orcleat config --memory 4g --cpus 2, or hand-write a[resources]section in~/.config/cleat/configor<project>/.cleat([box.<name>.resources]to change one box). The editor's choices are built from your actual machine. Cpus come from the core count Docker reports. On a VM bigger than 8 GB the memory ring climbs in real stops to the VM's size, so a 24 GB VM can be asked for all 24 GB. Past 8 GB it annotates instead of blocking, with a note that gets blunter as the number climbs and the full reason at the whole-VM value (a box that grows into everything starves the daemon and the VM's own OOM killer starts firing). Repo-supplied values are capped (8g memory, your core count for cpus). CPU is unlimited unless you set it -- an idle core costs nothing. If a session is ever OOM-killed (often a test runner spawning one worker per host core), Cleat says so and how to fix it: raisememory, cap workers (jest --maxWorkers=2), or setcpus --init-- a real PID 1 reaps orphaned processes, so long sessions can't wedge on zombie buildup andcleat stopis instant- Numeric UID/GID validation in the entrypoint to prevent injection attacks
- Node.js bookworm-slim base image with minimal attack surface
Images are published multi-arch (amd64 + arm64): Apple Silicon runs natively, never under emulation. cleat prune clears cleat's own stale images (cleat also offers this automatically when they pile up). Boxes and other projects' images are never touched.
Closing a terminal ends the session but leaves the box running, still reserving its memory ceiling. On every interactive start, Cleat stops other idle boxes that are safe to stop (detached, no agent running, idle past a 30-minute grace) and tells you what it freed. A box working unattended (terminal left open, agent still running) is never touched. Disable with CLEAT_NO_IDLE_SWEEP=1. Tune the grace with CLEAT_IDLE_GRACE_MINS.
If your Docker VM memory is set too low, or swap is left at the default, Cleat holds the launch on a prominent amber banner and waits for you to press Enter, instead of letting the warning scroll past unread into Claude's TUI. It fires only on a genuine config problem (never the transient overload notice) and only on a real interactive terminal, so cron, pipes and CI never block. Press Enter to launch anyway, Ctrl-C to go fix Docker, or set CLEAT_NO_DOCKER_GATE=1 to keep the advisory but skip the hold.
Disk is watched the same way. Every box shares one Docker store, so a box that reads 100% full is really the whole store filling up. When it crosses about 85% full with little free space Cleat drops a one-line advisory naming what you can reclaim (cleat storage shows the full breakdown, cleat prune --cache clears the shared build cache). When it crosses 95% with under 10 GB free it holds the launch like the memory gate, with CLEAT_NO_DISK_GATE=1 to skip the hold. The trigger is the fill percentage, so a 60 GB disk and a 1.8 TB one trip at the same fullness. The fix guidance is written for your engine (Docker Desktop, OrbStack, Colima, native Linux or WSL). If a store is too full for a box to start at all, Cleat catches the out-of-space error and prints the same guidance.
Capabilities
Capabilities are opt-in features that extend what the container can access from the host. They are disabled by default: the baseline container is locked down and each capability explicitly widens the boundary.
Enable capabilities
# Interactive wizard
cleat config
# Direct mode
cleat config --enable git
cleat config --enable ssh
cleat config --enable env
# One-off (session only, no config change)
cleat --cap ssh start
Available capabilities
| Capability | Category | What it does |
|---|---|---|
git | mount | Mounts ~/.gitconfig (read-only). Commits inside the container use your host identity. |
ssh | mount | Mounts ~/.ssh (read-only). SSH agent forwarding if SSH_AUTH_SOCK is set. |
env | mount | Auto-loads env vars from ~/.config/cleat/env (global) and .cleat.env (project). |
hooks | mount | Runs the hooks in ~/.claude/settings.json on the host. Hooks from the project's own .claude/settings*.json never run there. |
gh | mount | Mounts ~/.config/gh (read-write). gh auth login inside container writes tokens to host. |
docker | sandbox | Mounts /var/run/docker.sock. docker, docker compose and anything that talks to the daemon run against your host: sibling containers, zero overhead. Sandbox-escaping. See security note below. |
unsafe-rm | guard | Answers Claude Code's un-bypassable "dangerous rm" prompt so rm/rmdir run unattended. That prompt survives --dangerously-skip-permissions by design (an upstream circuit breaker). Cleat installs a PermissionRequest hook that answers whenever the command invokes rm/rmdir at the top level, so chained cleanups (mkdir -p $S && rm -rf $S/*) run unattended. A command that removes nothing is never answered. This disarms a real guard: /workspace and ~/.claude are read-write host mounts. Global or --cap only, never a project .cleat. Default off, red warning every launch. |
Cloud CLI caps (
az,aws,gcloud) and the lazy-install framework that backed them shipped in v0.11.0 / v0.12.0 and were removed after v0.12.3. They bloated first-run time without earning their weight. Install the CLI on the host and pass credentials via theenvcap.
Per-box capabilities
Every box reads the same project .cleat. A box that needs something different
declares its own section, which replaces the project default instead of
merging with it. That is what lets a box hold fewer capabilities than the
project:
[caps]
git
ssh
# review declares its own, so it gets git and nothing else
[box.review.caps]
git
# declared and empty: zero capabilities
[box.locked.caps]
# only memory is declared, so cpus stays inherited
[box.heavy.resources]
memory = 8g
Comments go on their own line. A # after a section header is read as part of
the header name, not as a comment.
<kind> is caps, resources, setup or fork. A section you don't declare
is inherited from the bare one. Declared-but-empty is a real value, not absence,
which is what keeps a locked-down box locked down when the project later gains a
cap. caps, setup and fork replace wholesale. resources falls back key by
key, so declaring memory for a box leaves its cpus inherited.
cleat config <box> edits those sections for you. Enabling a cap on a box that
inherits materializes the inherited set first, so --enable gh against a project
[caps] of git ssh writes git ssh gh. Edit a box back to exactly what it
would inherit and the section is dropped, restoring inheritance. Empty a declared
section and it keeps its bare header. cleat config <box> --list names the box
and marks every value declared or inherited, so you can see at a glance which
project edits will still reach it.
Per-box sections are project-only. The global config ignores them with a warning,
because a box name belongs to one project. Resources from a project .cleat are
still clamped (8 GB memory, your daemon's core count for cpus). Per-box sections
are clamped the same way. Environment variables are the one thing that stays in
sidecar files: .cleat.<box>.env, falling back to .cleat.env.
Mixed-version teams.
.cleatis committed. A Cleat older than this release cannot see[box.*]sections. It falls back to[caps], so a per-box reduction reads as the permissive project set on an older CLI. That fails open. If a lockdown box has to hold across a team, either make[caps]itself the restrictive set or make sure everyone is on a current Cleat. The first time a per-box section appears,cleat configstamps a two-line comment at the top of the file naming the minimum version that reads them.
Leftover
.cleat.<box>files from before this release are no longer read. A box with one starts on the project defaults and says so at launch, naming the sections to move its config into.
Display categories
The post-launch summary and cleat status group active caps by behavior:
- mount (green):
git,ssh,env,hooks,gh. Bind-mount auth/identity, no install. - sandbox (amber):
docker. Mounts the host socket, breaks isolation. - guard (red):
unsafe-rm. Disables the box's delete-safety prompt.
When only one mount or sandbox category is active the line collapses to a single coloured row. A guard cap always renders in the labeled block, as do caps that span categories. The landing page mockups use the same UI so the CLI and the marketing copy stay in lockstep.
Workspace trust: project .cleat approval
A project's .cleat file lives in the repo. Whoever controls the repo controls that file. Cleat won't silently apply a .cleat's capabilities on first run. Instead, on first launch inside a project with a .cleat, you'll see:
▸ Project .cleat [caps] requests host access: docker, env (beyond the sandbox)
Trust this project's .cleat? (applies its caps; approve once, undo with cleat untrust) [y/N]
The prompt names the exact section being approved, so a second box asking for something else is never mistaken for the project default:
▸ Project .cleat [box.ci.caps] requests host access: gh (beyond the sandbox)
Say yes and the approval is stored at ~/.config/cleat/trust. Next launch, nothing to see. Cleat silently applies the caps.
Approval is keyed on the canonical list of capabilities declared in .cleat, not the raw file. Comment edits and cap reordering don't invalidate trust. Adding, removing, or changing a cap triggers a re-prompt with an "…has changed since you trusted it" framing.
Only capabilities Cleat knows count. A name it does not know (a typo, a cap this version lacks or a line like docker,git) is ignored with a warning and grants nothing. It never appears in the prompt and never counts toward the approval. The same goes for unsafe-rm, which a project file can never grant. Cleat reads .cleat once per check: the caps it shows you, the caps it records and the caps it applies all come from that one read, so a file rewritten during a launch is checked again.
The hash is per box, so trust rows are keyed on (project, box). Editing one box's section re-prompts for that box only. Every other box keeps its approval.
A bare KEY line in the project env file (.cleat.env, or .cleat.<box>.env)
copies a variable from your shell into the box, so it joins the same approval.
The prompt lists those names as host variables, never their values:
▸ Project .cleat.env asks for host variables: GH_TOKEN (their values go from your shell into the box)
Trust this project? (grants what it asks for above, approve once, undo with cleat untrust) [y/N]
A project whose env file names no host variable keeps the approval it had.
Adding or removing a name asks again. Until approved the names are skipped with
a warning and the file's KEY=VALUE lines still apply. cleat trust and
CLEAT_TRUST_PROJECT=1 approve them along with the caps.
Scripting & CI
Non-interactive contexts (pipes, CI, cleat … | tee log) can't answer a prompt, so they default-deny: project .cleat caps are silently dropped, global config and --cap flags still apply. To opt in explicitly:
CLEAT_TRUST_SETUP=1 (or --trust-setup) approves a project's [setup] commands only. CLEAT_TRUST_PROJECT=1 never covers it: caps and setup are separate consent classes.
cleat --trust-project # one-off session flag, caps only
CLEAT_TRUST_PROJECT=1 cleat # env var (same effect)
cleat --trust-setup # one-off session flag, [setup] commands only
CLEAT_TRUST_SETUP=1 cleat # env var (same effect)
cleat trust # persist for this project, once (caps and setup)
Subcommands
cleat trust # trust the current dir's .cleat
cleat trust ~/proj # trust a specific project
cleat trust web # trust the current project's "web" box ([box.web.*])
cleat trust --list # show all trusted projects and boxes
cleat untrust web # untrust just the "web" box
cleat untrust ~/proj # remove a project's trust entry
What trust covers
| Source | Trusted? |
|---|---|
~/.config/cleat/config (global) | ✔ always: user's own file |
--cap <name> CLI flag | ✔ always: affirmative typed action |
<project>/.cleat | requires approval per-project, per-cap-set |
bare KEY lines in <project>/.cleat.env | requires approval, part of the same decision as the caps |
[setup] in <project>/.cleat | requires approval per-project, a separate consent class from caps |
cleat status never prompts: it's read-only and silently omits untrusted project caps when displaying.
Provision the box: the [setup] section
Some stacks need a tool the base image doesn't ship: a runtime, an SDK, a
database client. A [setup] section in .cleat lists the shell commands that
install it, run once per container as the coder user right after it's
created. You approve the exact commands once, the same way you approve
capabilities.
[setup]
curl -fsSL https://packages.microsoft.com/config/debian/12/packages-microsoft-prod.deb -o /tmp/msprod.deb
sudo dpkg -i /tmp/msprod.deb
sudo apt-get update
sudo apt-get install -y dotnet-sdk-8.0
That snippet shows the syntax on amd64. Microsoft's Debian feed ships .NET 8
for amd64 only, so the runnable examples/setup/dotnet
example uses the official dotnet-install.sh on arm64 (Apple Silicon).
A script <path> line inlines a project-relative script file at that position
instead of writing commands inline. List as many script directives as you
like, mixed with inline commands, in any order. Copy-paste examples live in
examples/setup/: dotnet (inline commands), python (one
script file) and rust (two script files). A script must live inside the
project, must not be a symlink and must be at most 1 MiB. One that breaks any of
these is refused and setup is skipped for that run.
Setup trust is separate from capability trust. CLEAT_TRUST_SETUP=1 (or
--trust-setup) approves it non-interactively, cleat trust approves both
caps and setup together and editing [setup] re-prompts without ever
recreating the container. Trust is per (project, box): cleat trust <box>
approves what that box resolves to, a [box.<name>.setup] section when it
declares one, otherwise the bare [setup].
A failed command prints its exit code and the box still opens. Fix .cleat
or the box, then retry with cleat setup.
Docker capability: testing dockerized apps
When docker is enabled, the container mounts the host Docker socket and can build, run and manage containers against the host daemon. Containers you launch from inside Cleat run as siblings on the host (not nested), so there's zero virtualization overhead:
cleat config --enable docker # persistent
cleat --cap docker # one-off session
# Then, inside the sandbox:
docker compose up -d
docker compose exec app npm run test:ci
docker build -t myapp .
docker run -v $(pwd):/app node:24 npm install
Cleat also bind-mounts your project at its host path inside the container (in addition to /workspace) and sets workdir there, so $(pwd) returns a host-valid path. This makes docker run -v $(pwd):/app … and relative paths like -v ./data:/data in docker-compose.yml resolve correctly on the host daemon.
The CLEAT_HOST_PROJECT environment variable is exported with your project's host path for scripts that want it explicitly.
Security note. The Docker socket grants root-equivalent access to your host. Any process inside the container that can reach
/var/run/docker.sockcan create a container that mounts/from the host and escape the sandbox (this is a property of Docker, not Cleat). When the capability is active, Cleat prints an amber warning on startup:! Docker socket mounted. Container can create host-level processesEnable this capability only in projects you trust and disable it when you don't need it. It's off by default and every activation is explicit (
cleat config --enable dockeror--cap docker).
Known limitations in v0.10.0:
- Literal
/workspace/…paths in-varen't translated. Docker errors cleanly that the source doesn't exist. Use$(pwd)or the host path instead. - Paths created inside Cleat at locations that don't exist on the host (e.g.
/tmp/scratchaftermkdir -p /tmp/scratchinside Cleat) will be created on the host as empty directories. Keep bind-mount sources under your project path.
Docker autopilot
Daemon down after a reboot? Run cleat and it starts Docker for you, waits
with a spinner, then continues your command. On macOS that means Docker
Desktop, OrbStack, or Colima (named Colima profiles included), on Linux Docker
Desktop or a rootless engine via systemctl --user, from WSL2 the
Windows-side Docker Desktop when interop is enabled. Where it can't start Docker safely it prints the
exact fix instead: a root-owned Linux engine (or an in-distro engine inside
WSL2, which wins over the Windows Desktop) gets sudo systemctl start docker,
a socket you can't write means Docker is up and you're not in the docker
group (the message hands you sudo usermod -aG docker <user>) and a remote
endpoint (tcp://, ssh://, npipe://, fd://) is refused with "start it
where it runs". Fires only on session verbs and only in an interactive
terminal, so scripts and CI are untouched. The wait is bounded
(CLEAT_AUTOSTART_TIMEOUT_SECS, default 90s) and CLEAT_NO_AUTOSTART=1
turns it off.
No Docker installed at all? Cleat offers to install it, consent-first: on macOS a menu of Docker Desktop / OrbStack / Colima via Homebrew's official packages (casks for Desktop/OrbStack, formulae for Colima, with the licensing difference stated), on Linux Docker's official install script downloaded to a private temp dir and run under sudo only after you say yes, on WSL2 the Windows-side Desktop via winget. The exact command is always shown, the default is No and scripts are never prompted.
Environment variables
The env capability controls automatic loading of env files. The --env and --env-file flags always work, regardless of whether the capability is enabled:
# These always work (bypass capability gate)
cleat --env GH_TOKEN=abc123 start
cleat --env GH_TOKEN start # inherit from host
cleat --env-file .env.local start
# These require the env capability
# ~/.config/cleat/env ← global
# .cleat.env ← project-specific
In an env file, KEY=VALUE sets a value and a bare KEY copies that variable
from your shell. Your own sources (~/.config/cleat/env, --env-file and
--env KEY) do that as they always have. A project env file (.cleat.env or
.cleat.<box>.env) sits in the repo, where a clone or the box itself can edit
it, so its bare names go through workspace trust: the trust prompt lists them as
the host variables the project asks for and a new name asks again. Until you
approve, they are skipped with a warning while the file's KEY=VALUE lines
still apply. A project env file that is a symlink is not read at all.
Configuration drift detection
When you change capabilities or env keys after a container was created, Cleat detects the mismatch the next time you run cleat, cleat resume, or cleat claude. On a TTY it prompts you to recreate (a plain-text line, no box):
▸ Config changed since cleat-<project> was created: caps or env keys differ from the running setup
Recreate cleat-<project> now? [Y/n]
Accepting removes the container and rebuilds it with the new caps/env. Sessions persist on the host (~/.claude/projects/<key>/) and are never touched. Declining keeps the existing container.
A Cleat version bump on its own does not trigger this: the drift check looks only at caps and env keys. Image freshness is handled separately and is also content-aware: the on-start image-refresh prompt fires only when the image's actual contents change (the entrypoint, the clipboard or browser bridge, the Dockerfile, or the pinned base), not on every version bump. The base image is pinned by digest, so a routine release leaves your container and everything you installed in it untouched. A base or security update ships through the same refresh prompt.
Non-TTY runs (CI, scripts) print the notice and continue with the existing container. They never auto-destroy.
Config files
~/.config/cleat/config ← global capabilities, [resources], [kits], [fork] dir,
[browser] origins added with cleat browser allow
~/.config/cleat/env ← global env vars
~/.config/cleat/forks/ ← fork workspace copies (default root, moved by [fork] dir)
<project>/.cleat ← project capabilities (extends global), [resources], [setup],
[fork] exclude, plus any [box.<name>.<kind>] overrides
<project>/.cleat.env ← project-level env vars (bare KEY lines need trust)
<project>/.cleat.<box>.env ← per-box env vars (falls back to .cleat.env)
~/.config/cleat/state/hook-drops.log ← hook events the bridge refused and spool discards (hooks cap)
~/.config/cleat/state/hook-runs.log ← hook events handed to your hooks (hooks cap)
~/.config/cleat/state/stage/ ← a login on its way into a box or an account,
built outside every mount (empty between writes)
One project, one .cleat. Boxes scope their caps, resources, setup and fork
excludes into [box.<name>.<kind>] sections of that file. Env vars are the
exception and keep their own sidecar.
A project's .cleat and env files count only when they are regular files. A
FIFO, a socket or a link to a device at either name reads as absent, so a box
cannot hang a launch by planting one there.
cleat config never edits a project file through a link. The project folder is
the box's workspace, so the new .cleat is built under ~/.config/cleat and
renamed into the project, where nothing at the final name is followed. A
.cleat that is a symlink, a directory or larger than 256 KB is refused with
Refusing to edit. To edit a shared .cleat, edit the file the link points to.
.cleat.env is never created over a link either. Project edits need
~/.config/cleat to be writable.
Terminal output
Cleat uses a clean, consistent output format with no Docker noise.
Startup
✔ Image ready (cached)
✔ Container started
✔ Auth shared
✔ Claude launched
Container: cleat-backend-a1b2c3d4
Project: ~/backend → /workspace
Caps: git, ssh
Slow operations (image build, container start) show animated braille spinners that resolve to checkmarks. When stdout is not a TTY (piped, CI), spinners degrade to static lines.
Exit
✔ Session ended. Resume with: cleat resume
Docker's "What's next?" promo text and clipboard watcher cleanup messages are suppressed.
Hooks
When the hooks capability is enabled, the hooks in your ~/.claude/settings.json run on the host, as if you weren't using a container. That file is the only place a host hook comes from. A project's own .claude/settings.json and .claude/settings.local.json live inside the read-write /workspace mount, so a command read from one would be a command the box can write. Their hooks do not run on the host. With the capability on, a project that defines some gets a note at session start saying so. Copy the ones you want into ~/.claude/settings.json.
This capability is the one place where the box's activity deliberately runs a command outside the cage. That is the feature and it is your call, but enable it knowing the shape. The command runs on your host, as you, uncontained. The agent is what generates the events that trigger it, so it chooses when your hooks run and what is on their stdin. Every session that will run a host hook says so:
! Host hooks enabled. The box chooses when they run and what is on their stdin.
A hook command that names a path in the repo ($CLAUDE_PROJECT_DIR/.claude/hooks/format.sh, npm run hook, make lint) resolves inside /workspace, which the agent edits as ordinary work. Point a hook you care about at a script outside the project. Treat the set of hooks you have enabled as the set of things the box can ask your host to do, at a time of its choosing.
cleat config --enable hooks # enable persistently
cleat --cap hooks start # enable for one session
How it works
- Cleat creates a settings overlay that replaces hook commands with an event forwarder inside the container
- Project settings files that exist when the box is created get an overlay too, so their hooks do not run in the container either. Cleat reads each one once and only from a regular file of at most 1 MB inside the project. A link, a file inside a linked
.claudeor a larger file gets no overlay and a warning at start. A fork box's overlays are refreshed from its own copy - A host-side bridge reads forwarded events, looks the event up in
~/.claude/settings.jsonand runs the matching commands on the host - Before anything runs, the event is validated and its path fields are rewritten from
/workspace/...to your real project path (the fork's copy for a fork box). A path has to land inside the project on disk, symlinks included. An event that fails a check is dropped and logged to~/.config/cleat/state/hook-drops.log. When the session ends it tells you how many were dropped. Every event handed to your hooks gets a row inhook-runs.logbeside it - Event JSON is piped to stdin and matchers are respected. The hook runs in the event's working directory, translated the same way. Each command is bounded per event (15s for PreToolUse and PostToolUse, 120s for Stop and SubagentStop, 30s for UserPromptSubmit and anything else), through
timeout,gtimeoutorperl. A host with none of the three runs it unbounded - An edit to an existing hook's command applies from the next event with no restart. A hook for a new event type or a new matcher needs
cleat resume, because the box forwards only the events its overlay names - Commands like
osascript, local scripts and anything host-specific work transparently
Validation narrows what the box controls from any host path and any argv to any in-project path plus free text. It does not make hooks a boundary. A hook that pastes a field such as message or prompt into a shell or osascript still runs text the box chose. A hook that reads nothing from stdin is not affected by validation at all.
Browser bridge
When Claude Code or any tool inside the container calls open or xdg-open with a URL, Cleat can open it in your host browser. OAuth callbacks are proxied back to the container, so a login started inside a box completes without copy-paste. No capability needed.
Cleat checks the origin first. A box that can aim your host browser anywhere is a box that can aim it at your own logged-in accounts, so the URL is opened only when its origin is on a list Cleat ships. Every login Cleat knows about is on that list by default and there is nothing to configure. A login at any other origin is refused and printed in full for you to open by hand when the session, cleat shell or cleat login ends, with the one command that allows it next time. By default a device-flow page or a plain link is never auto-opened, whatever its origin. You click the link the tool prints, so it is never reported as refused:
cleat browser origins # what your sandbox is allowed to open
cleat browser allow auth.example.com # add one, for every box
The full list, with the login each origin serves, is in the browser origins table on cleat.sh.
The list can never be complete. That is arithmetic rather than a gap: an Atlassian site is your own site, a self-hosted GitLab or GitHub Enterprise is your own hostname, an MCP server names its own authorization host, one per server. You add those once. AWS is not shipped either: aws sso login opens oidc.<region>.amazonaws.com, one host per AWS region, so allow the region you use. The same goes for a login Cleat has not catalogued yet. Not every tool documents which page it opens. A few ask their own server for the address at login time, so it can move without the tool changing.
An origin on the list can still redirect your browser somewhere else, because following a redirect is what browsers do. The list bounds the first hop, not the last one.
One click, one tab. Your terminal already opens a clicked link itself, so the bridge defers plain links to it and opens only what the terminal will not: an OAuth authorize URL at an allowlisted origin. Off a terminal (a pipe, cron, nohup, cleat login) a plain link is deferred too, because nobody is watching the browser during an unattended run. Override with CLEAT_BROWSER_BRIDGE=always, which opens every URL the box picks at any origin, or off, which never auto-opens while the login callback proxy still runs for a listed origin. always does not finish every login. The callback that brings a hands-free login back into the box still needs the origin on the list, so that kind of login at an unlisted origin opens its tab and then waits. cleat browser allow fixes that one. When the mode is always or off, the launch summary says so on a Browser: row, so a bypass set in a shell profile months ago is never invisible.
At most 6 opens a minute and 30 a session. Every open is a real request from your host, so the bridge caps how often a box can cause one, in every mode including always. The minute is counted across every session, shell and login on the box. A URL past either cap is not opened. When the session, shell or login ends, Cleat says how many were held back and prints up to three of them to open by hand. No real login comes near the cap.
CLEAT_BROWSER_ORIGINS appends to the shipped list for one shell, never replaces it:
CLEAT_BROWSER_ORIGINS="auth.acme.example,oidc.eu-west-1.amazonaws.com" cleat
Missing a login worth shipping? Cleat prints the exact origin when it refuses one, so paste that into an issue and it can ship as a default.
Host connectivity
Containers can always reach services on the host via host.docker.internal. No capability needed. User-defined hooks, MCP servers and HTTP endpoints on the host work out of the box.
# In .cleat.env (with env capability enabled)
CLAUDE_VISUAL_URL=http://host.docker.internal:3200
On any engine other than Docker Desktop, Cleat adds --add-host host.docker.internal:host-gateway automatically. That covers Colima, Rancher Desktop, Lima and rootless Linux, on a Mac as well as on Linux. Docker Desktop provides it natively, so Cleat leaves it alone there.
Which uid the box runs as
Cleat maps the box user to your host UID, so files the agent writes come back owned by you. That holds because your uid usually means the same number inside a container as outside it.
A user-namespaced engine breaks that assumption. Rootless Docker maps you to container uid 0 and your subordinate uids to container 1, 2, 3, so your own number lands on a subuid that owns nothing. Docker Desktop for Linux remaps the same way without calling itself rootless. Since v1.5.1, on a Linux host, Cleat measures the mapping once per engine. It mounts a directory you own into a throwaway container, reads back the uid that container sees and caches the answer in ~/.config/cleat/state/uidmap. On a Linux engine that does not remap, the measurement is your own uid and nothing changes. macOS never measures: every macOS engine runs the daemon in a VM whose file-sharing layer already presents your files as yours, so your own uid is passed exactly as before.
A box created on a namespaced engine before v1.5.1 keeps the old number frozen in its container config. It now says so at session start and names the fix: cleat rm, then start it again.
Auto-upgrade notifications
Cleat checks for new release tags at most once every 10 minutes via git ls-remote --tags (a lightweight network call that fetches no objects). When a newer version is available, you'll see a notice before Claude Code launches:
┌──────────────────────────────────────────────────────┐
│ Update available v0.4.0 → v0.5.0 │
│ Run cleat update to install the latest version. │
└──────────────────────────────────────────────────────┘
- The check runs at most once every 10 minutes. It will not slow down subsequent launches.
- The result is cached in
update_checkunder~/.config/cleat/state/(older installs are migrated automatically from~/.cleat/.update_check). - The notification is informational only. It will never interrupt or block your workflow.
- To upgrade, run
cleat update. To also update Claude Code inside containers, follow up withcleat rebuild.
Clipboard support
Clipboard works out of the box. When Claude Code (or any tool) calls pbcopy, xclip, or xsel inside the container, the text is copied to your host machine's clipboard -- no X11, display server, or special terminal features required.
How it works
A host-side clipboard watcher starts automatically alongside every Claude Code session. The container writes clipboard data to a shared file via a bind mount. The watcher claims the file and copies the content to your real clipboard using pbcopy (macOS), xclip, xsel, or wl-copy (Linux). Each copy is picked up once and never replayed: the watcher consumes the payload as it delivers it and discards anything left over from an earlier session instead of replaying it onto your clipboard.
┌─────────────────────────────┐ ┌─────────────────────────────────┐
│ Docker container │ │ Host │
│ │ │ │
│ Claude Code │ │ │
│ └─ echo "text" | pbcopy │ │ │
│ └─ writes to ──────────────> ~/.config/cleat/run/<box>/clip/│
│ /tmp/cleat-clip/ │ │ └─ watcher claims the file │
│ │ │ └─ pbcopy / xclip │
│ │ │ └─ ✔ clipboard! │
└─────────────────────────────┘ └─────────────────────────────────┘
An OSC 52 fallback is available for terminals that support it, used automatically when the file bridge is not active.
# These all work inside the container -- including from Claude Code:
echo "hello" | clip # dedicated helper
echo "hello" | pbcopy # macOS-style
echo "hello" | xclip -selection clipboard # Linux-style
echo "hello" | xsel --clipboard # Linux-style (alternative)
git log -1 --format=%B | clip # copy last commit message
Limits: Payloads are capped at 100KB. Paste (xclip -o, xsel --output, pbpaste) is not supported -- clipboard is copy-only.
Image paste (ctrl+v)
Paste a screenshot into Claude Code inside a box the way you would anywhere
else. Copy the image on your host, click into the box's Claude prompt and press
ctrl+v. About a second later the prompt shows [Image #1].
It is ctrl+v, not cmd+v. The box's Claude Code runs as Linux, which binds
image paste to ctrl+v. On a Mac, cmd+v is captured by your terminal as a text
paste and never reaches Claude Code.
Nothing to install or enable. At session start Cleat drops a small xclip shim
into the box ahead of the real one. A host watcher answers the box's image
probes from your clipboard. An existing box picks it up on its next
cleat start or cleat resume, no recreate needed.
The channel only ever carries an image. The box cannot ask the host for
clipboard text, so passwords and tokens can never travel it. The served image is
validated on the host by magic bytes (a real PNG, JPEG, GIF or WebP, nothing
over 10 MB) then carried in with docker cp, never a shared-folder write. Turn
it off entirely with CLEAT_NO_CLIPBOARD_IMAGE=1.
Companion tools
If you run Cleat on macOS, Clyde is a menu-bar
monitor for Claude Code sessions. It tags sessions running inside a Cleat box with
a cleat badge so you can tell caged sessions apart from native ones. It also
shows attention alerts when a session needs you. Enable the host hook bridge once
with cleat config --enable hooks and Clyde picks up your boxes automatically.
Separate MIT project, no affiliation.
Troubleshooting
Clipboard not working
If pbcopy/xclip/xsel inside the container doesn't copy to your host clipboard:
- Check the bridge is active -- inside the container, run
ls /tmp/cleat-clip/.host-ready. If the file exists, the host watcher is running. - Check clipboard commands on the host -- the watcher needs
pbcopy(macOS),xclip,xsel, orwl-copy(Linux) available on your PATH. - Rebuild the container -- if you upgraded from an older version, run
cleat rm && cleat startso the new clipboard mount is created. - Large payloads -- clipboard is capped at 100KB. For larger content, write it to a file in
/workspaceand copy from the host.
Docker not running
An interactive cleat starts Docker for you (see Docker autopilot above), so
you rarely see a raw daemon error now. If Cleat prints Docker isn't running, run
the exact start command it shows. This happens by design when the auto-launch can't
help: a script or CI run (no TTY), CLEAT_NO_AUTOSTART=1, a root-owned Linux engine
or an in-distro WSL2 engine (the message hands you sudo systemctl start docker), a
WSL2 distro with interop disabled (start Docker Desktop on Windows and enable WSL
integration for the distro), or a remote tcp:///ssh:///npipe:///fd://
endpoint (start it where it runs). Docker is running, but you can't reach its socket is permission, not a down daemon: add yourself to the docker group with the
printed sudo usermod -aG docker <user>, then log out and back in. Docker isn't installed instead? Take the install offer, or run the printed install command.
Permission denied on install
# If /usr/local/bin is not writable, the installer uses sudo automatically.
# You can also install to a custom location:
ln -sf "$(pwd)/bin/cleat" ~/.local/bin/cleat
Container naming
Each container is named cleat-<dirname>-<hash> where the hash is derived from the full absolute path of the project directory. This means two projects with the same directory name (e.g. ~/code/client-a/api and ~/code/client-b/api) get separate containers automatically. The container name is printed before every session so you always know which sandbox you're in.
Rebuilding after Claude Code updates
The Claude Code CLI is baked into the Docker image. To get the latest version:
cleat rebuild
Files created as root
This shouldn't happen. The entrypoint maps your host UID/GID. If it does, check that Docker is passing through HOST_UID and HOST_GID correctly:
cleat shell
id # should show your UID/GID
Uninstall
cleat clean # remove all containers + image
cleat uninstall # remove CLI symlinks
rm -rf ~/.cleat # remove the repo clone
Your project files and ~/.claude credentials are never touched.
If cleat came from a Homebrew keg rather than this installer, the commands defer
to brew. cleat update runs brew upgrade cleatdev/tap/cleat. cleat uninstall
asks first, because brew uninstall removes the whole keg where this command
normally just drops a symlink, then runs it on a yes. Deleting the prefix symlink
by hand would leave brew believing cleat is installed while the command is gone
from your PATH. cleat install refuses outright: brew already linked it.
When brew isn't on your PATH (a shell that never ran brew shellenv, or cron)
nothing is run and the command to run is printed instead. Same for
cleat uninstall outside a terminal: it never removes an install unattended.
Updates work the same either way. The on-start "update available" offer,
its throttle and its memory of a version you declined are identical on both
install methods. Accepting it runs brew upgrade on a Homebrew install and
moves the checkout on a script install, then continues your session on the new
version.
Switching install method loses nothing. Config, trust, boxes, fork
workspaces and Claude history live outside the install (~/.config/cleat,
~/.claude, your project's own .cleat). Boxes are identified by project path
rather than by where Cleat lives. Remove the install you have, then add the
other. Every box is exactly where you left it.
One cleat per machine, from cleat's own installers. The installer and cleat install refuse to add a second
install at a different path, because from then on PATH order decides which one
runs. Re-installing over the path you already have is fine, that is an upgrade.
A different path is refused and --force replaces it
(curl -fsSL https://cleat.sh/install | bash -s -- --force). A Homebrew keg is
never replaced, even with --force. cleat status lists them all when it finds
more than one.
Contributing
Contributions are welcome! Please open an issue or submit a pull request.
git clone https://github.com/cleatdev/cleat.git
cd cleat
# Make your changes on main, test locally
./bin/cleat start ~/some-test-project
Releasing
Releases are cut by tagging a commit on main:
git tag v0.3.0
git push --tags
The installer and updater both resolve the latest semver tag automatically. No release branch is needed.
License
Cleat. Give the agent a cage, not your keys. | Docker sandbox for AI coding agents | cleat.sh