Plastic Turtle is a pretty good sandbox. It gives you (and your agents!) a reasonably safe environment to play in, keeps bad stuff from getting in, and makes it easy for good stuff to get out.
Plastic Turtle gives you a low friction means of working with your projects in dedicated, ephemeral Tart VM instances. It includes a domain-based outbound firewall that allows you to define exactly which domains the sandbox VM and programs running on it are allowed to communicate with. It can also forward ports from services in the VM to the host.
Plastic Turtle is intended to allow you to --dangerously-skip-permissions; however, it's a nice place to play regardless of whether you're using it with an LLM or not.
You'll need some prerequisites! If you don't already have Tart installed, you're gonna need it! You can grab Tart and get a macOS Tahoe base image by doing the following:
$ brew install cirruslabs/cli/tart
$ tart clone ghcr.io/cirruslabs/macos-tahoe-base:latest tahoe-base
# this is going to download a ~27GB macOS Tahoe base image; one sec...The easiest way to install Plastic Turtle is using Homebrew:
brew install kenkeiter/tap/plasticturtleThat installs the command as plasticturtle, with turtle as a shorter
alias for it. In the off chance you have a conflicting turtle, you can remove the alias.
Once you've got it, using Plastic Turtle (turtle) with your project is pretty simple:
cd ~/code/myproject
turtle init # set up your project's .plasticturtle
turtle shell # clone, boot, and drop into a shell running in the VMThe above example does the following:
-
🐢 Add a
.plasticturtleconfig to your project – From your project directory, runturtle init; you will be prompted to choose a base image and other parameters! A.plasticturtlefile will be created in your project directory. -
🏖️ Play in the sandbox using
turtle shell– Any time you are within your project directory, runningturtle shellwill give you a safe space to play in – an ephemeral VM. You can useturtle shellas many times as you want from the same project, and all shells will run on the same VM. Once all shells are closed (⌘+D), the VM is automatically stopped and garbage collected. -
🧹🐢 Don't worry about cleanup! – The sand stays in the turtle. VM instances are ephemeral, and are deleted after you close the last shell for your project; cloned VMs are copy-on-write, so you won't use more storage than you need to.
Couple of #protips:
- If you (or an agent) make changes to your project's
.plasticturtleconfiguration, you must explicitly allow them by runningturtle allowwithin your project directory. Critically, this cannot be run from within the VM itself – but your project's.plasticturtleremains editable by the agent, which can be a nice way for the agent to understand its limitations and suggest changes. - Run
turtle listto see running VMs.
Plastic Turtle provides two types of isolation:
- a separate guest OS with its own kernel, filesystem, users, network stack running within the Apple Virtualization Framework.
- a network firewall that may be configured to prevent processes running in Plastic Turtle from accessing domains other than those they explicitly configure.
| Host | macOS on Apple Silicon (Tart is macOS/arm64 only) |
tart |
on PATH; developed against 2.32.1 |
| Guest image | ghcr.io/cirruslabs/macos-tahoe-base:latest is the tested image |
| Shell plugin | zsh (optional; the CLI itself works from any shell) |
| Build | Go 1.26+ |
v1 targets macOS guests. Linux guests boot and forward ports, but the directory share is not auto-mounted; see Linux guests.
brew install kenkeiter/tap/plasticturtleOr from source:
git clone https://github.com/kenkeiter/plasticturtle
cd plasticturtle
make build # -> ./bin/plasticturtle and ./bin/plasticturtle-softnet-shim
cp bin/plasticturtle bin/plasticturtle-softnet-shim /usr/local/bin/
ln -s /usr/local/bin/plasticturtle /usr/local/bin/turtle # optional short aliasplasticturtle-softnet-shim is only needed for the network firewall;
keep it next to plasticturtle (turtle setup looks for it there). If you do not use
restricted network policies you can skip it.
Pull the tested image once (first turtle shell would otherwise pull it inline,
inside the 120 s boot timeout):
tart pull ghcr.io/cirruslabs/macos-tahoe-base:latestThen add the zsh integration to ~/.zshrc:
source <(turtle zsh-hook)The plugin is small and deliberately unobtrusive. It installs a chpwd hook
that:
- walks upward from
$PWDfor a.plasticturtle, stopping at$HOMEor/; - runs
turtle _check-trust <dir>when one is found (exit0trusted,10new/changed,1error); - prints a one-line yellow warning on
10, once per directory change, not once per prompt; - preserves
$?across the hook, tolerateserrexit, andreturn 0silently if it is not onPATHor the plugin is sourced twice.
To use Plastic Turtle, your project must have a .plasticturtle file. This file configures the VM itself, files shared between the host and the VM, outbound network access, and port forwarding.
version: 1
image: ghcr.io/cirruslabs/macos-tahoe-base:latest
resources:
cpu: 8 # vCPUs
memory: 8192 # MiB
ports:
- vm_port: 3000
host_port: 3000
- vm_port: 5432 # host_port defaults to 5432
mounts:
- name: project # reserved: changes the mode of the always-present project share
mode: ro
- name: datasets
host_path: ~/datasets
mode: ro
- name: scratch
host_path: ./scratch
mode: rw
network:
policy: restricted
allow:
- "anthropic.com"
- "*.anthropic.com"
- "claude.com"
- "*.claude.com"
- "claude.ai"
- "*.claude.ai"| Field | Type | Required | Default | Rules |
|---|---|---|---|---|
version |
int | yes | — | must be exactly 1 |
image |
string | yes | — | non-empty after trimming; local VM name or OCI reference |
resources |
map | no | inherit from image | omit entirely to inherit both |
resources.cpu |
int | no | 0 = inherit |
0 or >= 1; negatives rejected |
resources.memory |
int (MiB) | no | 0 = inherit |
0 or >= 512; anything in 1..511 and negatives rejected |
ports[].vm_port |
int | yes | — | 1..65535 |
ports[].host_port |
int | no | same as vm_port |
1..65535; duplicate effective host ports across entries are an error |
mounts[].name |
string | yes | — | [a-zA-Z0-9_-]+, unique within the file |
mounts[].host_path |
string | see rules | — | required for every mount except project, where it is forbidden |
mounts[].mode |
rw | ro |
no | rw |
any other value is an error |
network.policy |
open | restricted |
required if network present |
open (when block absent) |
any other value is an error |
network.allow[] |
string | no | — | bare domain or *.domain; no scheme, port, path, or IP; forbidden under open |
Additional rules:
host_pathexpansion.~is the invoking user's home.~otheruseris rejected rather than silently treated as a relative directory. Relative paths resolve against the project directory, never the working directory. Every resolved path must exist and be a directory before the VM is cloned; a missing one is a hard error fromturtle shell(and a non-fatal warning fromturtle allow).- The
projectmount is implicit. The project directory is always shared, first in the list, at/Volumes/My Shared Files/projectin a macOS guest. Listingname: projectinmounts[]only changes itsmode; it may not redirect it elsewhere.
"Aha," you say "but if the .plasticturtle file is exposed in the VM, it can be edited from the VM!" Yep, that's by design. In my experiments, it's been a nice way for the agent to understand its limitations and also to request changes to support your application.
But obviously, you shouldn't blindly accept those suggestions – that's why turtle shell does not act upon a .plasticturtle configuration until you explicitly approve that configuration. When you run turtle allow it shows you a summary of what changed, and you must explicitly approve it before running turtle shell.
An example might look like this:
$ turtle allow
.plasticturtle in /Users/alice/code/myproject
image ghcr.io/cirruslabs/macos-tahoe-base:latest
resources inherited from the image
mounts
project /Users/alice/code/myproject READ-WRITE
datasets /Users/alice/datasets read-only
ports
VM 3000 -> host 3000 (reachable on 127.0.0.1 only)
network
open (unrestricted outbound)
Allow it? [y/N]:
Re-approval shows only what moved. The full summary is what you read the first time; the risk in an edit lives entirely in its delta, and reprinting the parts you already approved is how you stop reading them:
$ turtle allow
.plasticturtle in /Users/alice/code/myproject
Changed since you allowed it 3 days ago:
~ image ghcr.io/cirruslabs/macos-tahoe-base:latest -> some/other:image
~ mount datasets /Users/alice/datasets read-only -> READ-WRITE
+ mount ssh /Users/alice/.ssh READ-WRITE
- port VM 3000 host 3000
+ allow pypi.org
Allow it? [y/N]:
~ is a grant whose value changed, + one that appeared, - one that is gone.
An edit that changes no grant at all — a comment, reordering, whitespace — says
so instead of showing an empty list, and still asks, because the bytes changed
and trust is keyed on bytes.
Mechanics:
- Trust is
sha256over the exact bytes of the file, stored intrust.jsonagainst the canonical absolute path of the project directory. The approved bytes are stored alongside the hash, which is what the change list above is diffed against. The hash alone decides trust; the snapshot is only there so you can be shown what moved. - Any change to the bytes — a comment, whitespace, a new mount — invalidates it.
turtle shellthen fails with.plasticturtle has changed (or was never allowed). Review it, then run: turtle allowand never with a prompt. A prompt at that moment would teach you to approve a file you have not read, which is the exact failureturtle allowexists to prevent. The message does not distinguish "changed" from "never allowed"; the remedy is identical either way. - Because the key is the canonical path, moving or renaming the project requires re-allowing, and a project reached through a symlink is the same project as one reached directly.
turtle allowvalidates first: an invalid config cannot be trusted, and you are not asked about something that could never work.turtle allowon a config that has not changed since you approved it says so and exits0without prompting. Asking again for a yes you already gave, about a file that is identical to the byte, is how "y" becomes a reflex.- Answering anything but
y/yes— including EOF, i.e. a non-interactive run — declines and exits1withNot allowed.It does not print an error; you made a choice. turtle initrecords trust without a confirmation prompt, because you just answered every question the summary would have shown you.
This is why the zsh plugin exists: it tells you the moment you cd into a
project whose config has changed, rather than at turtle shell time when you are
already trying to get work done.
turtle init [path] Interactive setup; writes .plasticturtle and allows it
turtle allow [path] Show what the config grants, then trust its exact bytes
turtle shell [path] [--persist] Enter the project's VM, creating it if needed
turtle ports [path] [--global] Configured forwards and their live status
turtle list Active instances with resource usage
path defaults to the working directory. Except for init, turtle walks upward
from it to the nearest .plasticturtle, so every command works from a
subdirectory.
Global flags: --json (honored by list and ports), -v/--verbose,
--version.
Hidden subcommands, listed for completeness — do not invoke them yourself:
turtle _supervise (the detached per-instance supervisor, fed its parameters as
JSON on stdin), turtle _check-trust <dir> (the plugin's fast trust probe),
turtle zsh-hook (prints the plugin).
Creates the instance if there is none, otherwise attaches to the existing one. Prompts about host-port collisions before the VM exists; shows a spinner while one boots (and prints the label once, without animation, when output is not a terminal).
If the running VM's snapshotted config differs from the currently allowed one,
it attaches anyway and prints
note: config changed since this VM started; changes apply after all shells exit.
-v additionally prints the instance name and the path of the supervisor log
on the create path — the easiest way to find that log.
turtle shell --persist boots the base image itself, instead of a
throwaway clone of it. Everything the guest writes — packages you install, tools
you configure, etc. — is still there next time, and every ordinary turtle shell
afterwards clones from an image that already has it.
That is the whole feature, and it is worth being clear about what it costs:
- The VM is not discarded at teardown. It is stopped gracefully (because that disk now matters) and leaves it alone. Nothing in Plastic Turtle will ever delete it — not teardown, not garbage collection.
- Whatever the guest does to that image sticks, including whatever a compromised dependency does. The sandbox stops being a sandbox for the duration.
cpu:andmemory:are applied to the image itself, so they outlive the session as its new defaults.- Only one VM can run an image at a time. A second project (or a stray
tart run) already running it is refused, with the name, before anything boots. For the same reason, other projects that clone the same image are better left until you have exited: a clone taken while the source is running is at best crash-consistent. - The image has to be a local VM. A registry reference is refused: changes
written into the OCI cache are discarded by the next pull. Make one once with
tart clone ghcr.io/…/macos-tahoe-base:latest tahoe-devand pointimage:at it.
Ephemerality is fixed when the VM boots, exactly like its mounts and image. So
--persist applies only to the shell that creates the instance: pass it while
one is already running and it says so and attaches anyway, and a shell
entering a persistent VM without the flag is told that its changes are being
kept. While you are in one, the status banner reads Persistent rather than
Sandbox, and turtle list shows the mode in its own column.
The intended workflow is to persist deliberately and briefly — set the image up, exit, and go back to ephemeral shells:
turtle shell --persist # install, configure, break things
exit
turtle shell # a clone of everything you just did, throwaway again
Exit status:
| Status | Meaning |
|---|---|
| n | the remote shell exited with n |
1 |
Plastic Turtle itself failed (no config, untrusted, missing mount, boot timeout) |
255 |
the SSH session never happened or was lost mid-flight (ssh(1)'s convention) |
Diagnostics go to stderr, so the guest shell's stdout stays clean for redirection.
When stdin is a terminal, turtle shell gives the guest a PTY that mirrors the
host's: the same size, the same control characters (erase, interrupt, suspend,
flow control), and — where it can — the same TERM.
TERM needs a negotiation because terminals like Ghostty, kitty and WezTerm
ship a terminfo entry of their own and install it only on the host. A guest
handed a name it cannot resolve gets no cursor-movement capabilities, and the
first thing you notice is that backspace stops erasing. So it asks the
guest whether it knows the name, and if it does not, compiles the host's own
description into $HOME/.terminfo in the guest with tic. If that fails — no
tic, no writable home, an entry the host cannot describe — the session falls
back to xterm-256color, which is plainer but always works.
The whole exchange is bounded by a short timeout and never blocks the prompt: a
slow guest costs you a plainer TERM, not a slower shell. Nothing is written
outside the clone, which is discarded at teardown.
Runs a global garbage-collection pass, then reports every project with an instance record.
$ turtle list
PROJECT VM MODE STATE SESSIONS CPU % MEM DISK* UPTIME
/Users/alice/code/myproject pt-e3b5380ebc1df727-e0342b0a clone running 2 38.4 2.1G 29.8G 4m12s
/Users/alice/code/imagework tahoe-dev persist running 1 12.0 1.4G 31.1G 22m03s
* approximate: CoW clones share blocks with the source image.
| Column | Source |
|---|---|
PROJECT |
project path from instance.json |
VM |
clone name, pt-<project-id>-<8 hex> — or the image's own name under --persist |
MODE |
clone (destroyed at teardown) or persist (the user's image, left alone) |
STATE |
creating, running, stopping, dead — forced to dead if the supervisor PID is not alive, whatever the record claims |
SESSIONS |
session records whose process is still alive |
CPU % / MEM |
summed from ps over the supervisor's process tree |
DISK* |
du -sk of ~/.tart/vms/<vm> (honors TART_HOME) |
UPTIME |
now − createdAt |
DISK* is approximate and usually wrong in an interesting way. A fresh clone
occupies almost nothing, but the column will read something like 29.8G, because
du charges copy-on-write shared blocks to whichever path it walks first — often
the clone rather than the base image. The asterisk and the footnote are there for
exactly this reason. No smarter accounting is attempted.
--json emits an array (never null) of objects with keys project, vm,
state, sessions, cpuPercent, memBytes, diskBytes, uptimeSeconds.
$ turtle ports
VM PORT HOST PORT STATUS
3000 3000 forwarding
5432 15432 forwarding (remapped from 5432)
Status values:
| Status | Meaning |
|---|---|
forwarding |
instance is running and its supervisor's heartbeat is under 15 s old |
stale |
an instance record exists but the supervisor has stopped beating, or the instance is still creating / already stopping — the listener is not to be trusted |
inactive |
the project has no instance record; rows come from the config alone |
(remapped from <n>) is appended when the forward is not on the port the config
asked for.
--global needs no project: it sweeps every project with a live supervisor,
prefixes a PROJECT column, and appends conflict: also claimed by <path> when
two projects' records claim the same host port. Projects whose lock is busy are
skipped rather than waited on, so --global reports what it could see promptly
rather than everything eventually.
--json emits an array of objects with keys projectPath, vmPort, hostPort,
originalHostPort, status, conflict. Every key is always present.
Forwards are SSH local forwards implemented inside the supervisor with
golang.org/x/crypto/ssh — no ssh binary, no extra processes. They die with
the supervisor by construction, so there is no orphaned listener to clean up.
- Listeners bind
127.0.0.1only, never0.0.0.0. This is a sandboxing tool; a forward reachable from the LAN would defeat it. The bind address is a constant, not a setting. - Inside the guest, each tunnel dials
127.0.0.1:<vm_port>— a dev server bound to guest loopback is invisible at any other address. The one exception is probed once at setup: if<vmIP>:<vm_port>answers from the host, that address is used instead, covering services bound only to the guest's external interface. The choice is made once per forward, not per connection. - A service listening in the guest is reachable on the host at
127.0.0.1:<host_port>.
If a configured host port is already bound (or is a privileged port this user
cannot bind), turtle shell — which still owns a terminal at that point — offers an
alternative before the VM is created:
Port 5432 is in use on the host.
Forward VM port 5432 to host port [15432]:
The default in brackets is host_port + 10000 if that is free, otherwise a
kernel-assigned ephemeral port, and it is already bound while you read the
question, so it cannot be stolen out from under the prompt. Press Enter to accept
it or type any other port; a port that is also taken re-prompts. Without a TTY
there is no prompt: the automatic port is taken and reported.
turtle ports then shows forwarding (remapped from 5432).
Remaps are never written back to .plasticturtle. Trust is a hash of the
file's exact bytes, so writing to it would invalidate the hash and turn a routine
port collision into a security prompt. The remap lives in instance.json for the
lifetime of that instance and disappears with it.
The negotiation is done by turtle shell, not the supervisor, because the supervisor
is detached and has nothing to prompt on. The shell holds each probe listener
until the instant before it spawns the supervisor, which then re-binds; that gap
is a race, and the supervisor retries once if it loses.
What you get:
- A separate guest OS with its own kernel, filesystem, users and network stack, running under the Apple Virtualization framework.
- Exactly the host directories named in
.plasticturtle, and no others. By default that is one directory: the project. - Host ports opened only by explicit configuration, and only on
127.0.0.1. - No persistence. Every session group starts from a fresh clone of the image; writes inside the guest that are not under a shared directory are discarded at teardown.
What you do not get:
- The project directory is read-write from the guest by default. Anything
running in the VM can rewrite your repo, including
.plasticturtleitself and any git hooks,Makefile, or CI config in it. Setmode: roon the reservedprojectmount if you do not want that. Note: If.plasticturtleis modified, you must approve changes usingturtle allowbefore Plastic Turtle will allow futher interaction. - The guest has unrestricted outbound network access by default. An agent
in the VM can reach the internet, your LAN, and any service listening on your
host's LAN address. A project can opt into a domain allowlist with a
network:policy — see Network firewall — which changes this to default-deny. - No defense against a VM escape, and no hardening of the guest image beyond what the image ships with.
- Guest SSH host keys are not verified (see Security notes).
By default the guest reaches anything. A project can restrict outbound traffic to
a named set of domains by adding a network: block to .plasticturtle:
network:
policy: restricted # open (default) | restricted
allow:
- github.com # exact host
- "*.githubusercontent.com" # any subdomain (not the bare parent)
- registry.npmjs.orgUnder restricted, egress is default-deny: a connection is allowed only to
an address the guest learned by resolving a name on the allowlist. Everything
else — a hardcoded IP, an out-of-band resolver, IPv6, a domain not listed — is
dropped. Disallowed names resolve to NXDOMAIN, so tools fail fast and legibly
instead of hanging. Nothing in the guest needs configuring: curl, git,
package managers, and any other TCP client work transparently for allowed
domains and simply cannot connect for the rest.
Enforcement runs on the host, in a small shim that provides the guest's network itself. Install it once:
turtle setup # copies the shim into place; sudo makes it setuid-rootThis is required only for restricted projects; open projects are untouched
and need no setup. Re-run it after upgrading Plastic Turtle. A project whose policy is
restricted refuses to boot if the shim is missing or misconfigured — the
firewall fails closed rather than silently letting traffic through.
Tart resolves the softnet binary from its PATH; for a restricted project,
Plastic Turtle puts its shim there first, and tart hands it the guest's ethernet link. The shim
is the whole software-networking layer: it opens a NAT interface through
Apple's vmnet.framework (via cgo) and relays the guest's ethernet frames onto
it, enforcing the policy by DNS-pinning: it watches DNS answers for allowed
names, records the returned IPs in a short-lived (TTL-bounded) allow-set, and
drops any guest frame whose destination is not pinned. Root is used only to
create the vmnet interface; the long-lived relay then drops back to your user.
There is no external Softnet to install.
Plastic Turtle also chooses the sandbox's subnet before boot — the highest free /24
between 192.168.252.0/24 and 192.168.200.0/24, skipping any range a host
interface already uses — and passes it to the shim, which asks vmnet for exactly
that. This is not cosmetic: macOS 26 ignores com.apple.vmnet.plist and gives
raw vmnet clients a hardcoded 192.168.2.0/24, a range plenty of LANs are on.
- Covers all TCP (and UDP) to allowed domains, not just HTTP — because it gates on the destination IP, not on parsing application traffic. There is no proxy to configure and no TLS interception.
- IPv6 and QUIC to arbitrary hosts are blocked; clients fall back to IPv4/TCP for allowed domains.
- Shared CDN IPs are coarse. An allowed and a disallowed domain served from the same IP are indistinguishable once pinned; the allow-set is by address.
- DNS itself is a residual channel. The guest can still send queries to the gateway resolver (that is how resolution works at all), so a determined agent can exfiltrate small amounts of data by encoding it in DNS lookups. The firewall restricts where the guest can connect, not what it can ask to resolve.
- Subnet collisions. If the sandbox's subnet ends up on the same range as your LAN, host connectivity breaks. It picks an unused range before boot to avoid it, and re-checks after boot: on a collision it refuses with an explanation rather than leaving you with mysteriously broken networking.
Shares are Tart virtiofs directory shares (tart run --dir=<name>:<hostPath>[:ro]).
On a macOS guest they appear under /Volumes/My Shared Files/<name>, so the
project is at /Volumes/My Shared Files/project.
turtle shell does not use SSH environment forwarding (sshd rejects unlisted
AcceptEnv variables, silently). Instead it runs, in place of a bare login:
cd '/Volumes/My Shared Files/project' 2>/dev/null || cd "$HOME"; export PT_IN_VM_SESSION=1; exec "${SHELL:-/bin/sh}" -lexec, so your login shell owns the PTY directly: job control, SIGWINCH and
the exit status all belong to it. PT_IN_VM_SESSION=1 is there for nested
tooling that wants to know it is inside a sandbox.
v1 targets macOS guests. A Linux guest boots and forwards ports fine, but its
kernel does not auto-mount virtiofs shares, so the cd above falls through to
$HOME and you must mount by hand:
sudo mkdir -p /mnt/shared
sudo mount -t virtiofs com.apple.virtio-fs.automount /mnt/shared
cd /mnt/shared/projectAll shares appear as subdirectories of that one mount point, named by their
mounts[].name. To make it permanent inside an image you control, add to
/etc/fstab:
com.apple.virtio-fs.automount /mnt/shared virtiofs defaults 0 0
Plastic Turtle does not detect Linux guests and will not warn you; the design document's "print a hint if the login banner suggests Linux" is not implemented.
turtle logs into the guest as admin / admin, the Tart image convention. Both
password and keyboard-interactive are offered, because sshd images differ in
which one they advertise and a client offering only the wrong one fails with an
opaque "no supported methods".
Override with environment variables:
PT_SSH_USER=builder PT_SSH_PASSWORD=hunter2 turtle shellAn empty value is treated as unset — PT_SSH_USER= turtle shell is far more likely
to be an accident than a request to log in as nobody.
These are deliberately not .plasticturtle fields. That file is checked into
the repo and shared with everyone who clones it; credentials do not belong there.
Making them configurable per project would also make a hostile config able to
name the account it wants.
Host keys are not verified. turtle uses ssh.InsecureIgnoreHostKey(). The
reasoning, from internal/sshx/sshx.go: these are ephemeral VMs that Plastic Turtle
itself just cloned, reachable only over the local virtio network, with no stable
identity to pin — a known_hosts entry would be regenerated on every boot and
would train the user to click through mismatches. The threat this drops is an
attacker already on the host's virtio network impersonating a VM that exists for
minutes, and that is not a threat this tool defends against.
The trust boundary is turtle allow. Everything downstream of it — which image,
which host directories in which mode, which host ports — is whatever the file
said at the moment you approved it. Guest-side compromise is outside the
boundary and expected; a config change is inside it and must be re-approved.
Read the summary. It is not a formality.
--persist suspends the disposability the rest of this rests on. The
ordinary guarantee is that guest-side compromise costs you the clone and nothing
more, because the clone is destroyed minutes later. Boot the image itself and
that stops being true: whatever the guest writes is in the image, and every
later turtle shell clones it forward. It is a command-line flag, not a config
field, precisely so that a .plasticturtle can never ask for it — the decision
is made by the person typing, per invocation. Use it for setup you intend, and
go back to ephemeral shells afterwards.
Config is snapshotted at boot. The supervisor receives the fully resolved
config on stdin when it is spawned, and keeps it for the instance's lifetime.
Editing and re-allowing .plasticturtle while a VM is running changes nothing
about that VM: its image, mounts, modes and forwards are fixed. Changes take
effect the next time an instance is created, i.e. after every shell has exited.
This is also why a running VM cannot be made to mount a new host directory by an
agent editing the config from inside it.
State files are private. The state tree is 0700/0600: it names project
paths and forwarded ports.
Garbage collection only ever deletes VMs whose names match
^pt-[0-9a-f]{16}-[0-9a-f]{8}$ and that no instance record claims. Any
uncertainty — a busy project lock, an unreadable record — is resolved as "leave
it alone". Your other Tart VMs are never touched, including the one a
--persist instance is running: a record marked persistent takes the
stop-but-never-delete path, and the name it carries is not one the delete path
would accept anyway.
~/.local/state/plasticturtle/ # or $XDG_STATE_HOME/plasticturtle
├── trust.json # canonical project path -> {hash, allowedAt}
├── trust.json.lock
└── instances/
└── <project-id>/
├── lock # flock guarding this project's state
├── instance.json # the current instance record
├── heartbeat # mtime touched every 5 s by the supervisor
├── supervisor.log # truncated at each boot
└── sessions/<session-id>.json # one per live turtle shell
<project-id> is the first 16 hex characters of sha256(canonical project path), and it is embedded in the VM name, so the easiest way to find a
project's directory is to take the middle field of its VM column in turtle list.
There is no daemon. Every invocation reconstructs the world from these files
plus PID liveness checks; every record that names a PID also records that
process's start time, so a reused PID after a reboot is not mistaken for a live
supervisor. Mutations happen under the project's exclusive flock, never held
across a prompt or a boot.
~/.local/state/plasticturtle/instances/<project-id>/supervisor.log is the only
record of a failed boot — the supervisor is detached and has no terminal. Every
error turtle shell prints for a boot failure names the path. turtle shell -v prints
it too, on the create path. It is truncated at the start of each boot, so it
always describes the most recent attempt.
It logs the clone, the resource override, the tart run child's pid, the DHCP
address, when sshd started accepting, each forward and which guest address it
chose, and the teardown reason.
| State | Meaning | What to do |
|---|---|---|
creating |
the supervisor is cloning/booting | wait; the boot timeout is 120 s |
running |
booted, tunnels up, SSH available | — |
stopping |
the last session exited; teardown in progress | wait; the next turtle shell waits for dead on its own (up to 45 s) then makes a fresh VM |
dead |
the record's supervisor process is gone | the next turtle shell, turtle list, or GC pass reclaims it |
A row that says running with a stale status in turtle ports is a supervisor
whose heartbeat has aged past 15 s. Plastic Turtle deliberately does not kill it: a
live-but-wedged process's VM is not something a status command should destroy.
Almost never necessary — turtle shell and turtle list both garbage-collect first —
but in order of escalation:
turtle list # runs a global GC pass, then reportsIf the supervisor was killed (kill -9), the VM is orphaned but harmless: the
next turtle shell for that project notices the dead PID under the lock, force-stops
and deletes the leftover clone, clears the state, and boots fresh. turtle list and
its GC pass do the same without booting anything.
An orphaned --persist instance is reclaimed the same way with one difference:
GC stops the VM and stops there. The image is yours, so nothing in Plastic Turtle
deletes it, and the record is kept until the VM really has stopped — that record is the
only thing that says a VM is running with nobody watching it.
If a supervisor is alive but wedged, GC will not touch it by design. Stop it yourself, then let GC clean up:
kill <supervisorPid> # from instance.json; SIGTERM means "tear down now"
turtle list # reclaims the record and the cloneLast resort, if a clone survives everything above:
tart list # clones are named pt-<16 hex>-<8 hex>
tart stop --timeout 0 pt-<...>
tart delete pt-<...>
rm -rf ~/.local/state/plasticturtle/instances/<project-id>| Symptom | Cause |
|---|---|
.plasticturtle has changed (or was never allowed) |
the bytes differ from what was allowed, or the project moved. Read the diff, then turtle allow |
no .plasticturtle found at or above <dir> |
not in a project; turtle init |
mount "x": host path ... does not exist |
a host_path is missing. turtle allow warns about this; turtle shell refuses |
the VM did not become ready within 2m0s |
image still pulling, or the guest never got a DHCP lease. Check the log; pre-tart pull the image |
VM terminated unexpectedly; see .../supervisor.log |
the tart run child exited under a live session |
a stray pt-* VM in tart list |
run turtle list; the orphan sweep deletes pt-* VMs no record claims |
| backspace does not erase, arrow keys print escapes | the guest could not be taught your TERM and fell back. Check infocmp "$TERM" inside the guest; a guest image without tic cannot be taught one. TERM=xterm-256color turtle shell sidesteps it |
DISK* shows ~30G for a VM that just booted |
expected. See turtle list |
How can processes running in a Turtle shell know that they are running in the VM?
The $PT_IN_VM_SESSION environment variable is exported and set to 1 within shell sessions.
Plastic Turtle is released under the MIT license (see the LICENSE file).
Copyright (c) 2026 Ken Keiter