Skip to content

Changelog

Changelog

All notable changes to this project will be documented in this file.

Unreleased

Added

  • devsandbox agent-wrappers activate now takes --agents, so you can wrap only the agents you name and leave the rest running unsandboxed: --agents claude,codex, the flag repeated once per agent, or both. Omitting it wraps every supported agent that is installed, exactly as before. An unsupported name or an explicitly empty selection exits non-zero listing the supported agents and writes nothing to stdout, so a startup file never evaluates half a snippet; selecting an agent you have not installed stays a no-op rather than an error. See Tools: Shell wrappers.

Changed

  • Updated embedded bubblewrap to 0.12.0.

Fixed

  • Custom mount rules now mask files reached through symlinks without bubblewrap 0.12.0 aborting with Can't mount on symlink destination. See Custom Mounts.

v0.20.1 - 2026-08-25

Changed

  • readwrite git mode now mounts host files outside ~/.gitconfig: the ignore and attributes files, and the [include] targets your own global config names. Marginal next to the ~/.ssh and ~/.gnupg this mode already carries, and it reaches only files your config already points at, but it is new host surface and worth stating. readonly and disabled are unchanged. One consequence to know about if your global config lives solely at ~/.config/git/config: that file is now mounted read-only, like ~/.gitconfig always has been, so git config --global inside the sandbox fails with Device or resource busy instead of falling through to a throwaway ~/.gitconfig the host never sees. Use git -c user.email=... or git config --local. See what readwrite carries.

Fixed

  • Git now works when you launch devsandbox from a git worktree you created yourself, rather than one made by --worktree. A worktree's .git is a file pointing at <main-repo>/.git/worktrees/<name>, which sits outside the project mount - so every git command in the sandbox failed with fatal: not a git repository: (null), and so did anything built on git, from git status to pre-commit hooks to a test that shells out to git config. The shared git directory is now detected and mounted, read-only under the default readonly git mode and writable under readwrite and disabled. Note that it holds the whole repository - every branch and object, plus your other worktrees' metadata - so a readwrite sandbox launched in a worktree can write all of it. Sandbox state stays keyed on the worktree path, so an existing worktree sandbox keeps its overlay. A worktree of a submodule still cannot be mounted, but now says so in a warning at launch instead of failing silently later. See Launching inside an existing worktree.
  • Your global git ignore and attributes rules, and every [include] / [includeIf] block your global config pulls in, now apply in readwrite git mode too. That mode mounts your ~/.gitconfig verbatim, so its paths named host files the sandbox never had - and git ignores a missing core.excludesFile, core.attributesFile or include target with exit 0 and no warning, which made readonly strictly more faithful to your host config than the mode where commits actually land. A git status in a readwrite sandbox listed files your global ignore rules cover, and an identity kept in an [includeIf "gitdir:..."] block, or solely at ~/.config/git/config, was simply absent. devsandbox now mounts the files your config names, at the paths their values spell, on every backend. Values are not rewritten and no key is dropped. Note the residual limitations on the docker and krun backends, all of which come from ~ being /home/sandboxuser there and none of which affect bwrap: an [includeIf "gitdir:~/..."] condition is re-evaluated inside the sandbox and stops matching, so the identity falls back to the outer config's; a ~/ value that climbs back out of $HOME (~/../shared/ignore) is mounted where it resolves on the host, which is still where guest git looks when the two homes share a parent and misses only when they do not; and one file included twice under two different spellings is mounted for the first spelling only. Spell such conditions and values absolutely, and use one spelling per included file. See what readwrite carries.
  • Commits made in a --worktree sandbox with readwrite git mode now land on the host. The main repository's .git, which a worktree's objects, refs and index all live in, was mounted without an explicit type and so resolved to a throwaway overlay under the default split overlay policy - writes went to tmpfs on bwrap and to a container-local copy on docker and krun, and every commit was discarded at exit with no error at any point. It is now pinned to a writable bind mount, which is what the mode always promised - but only where the mount mode would otherwise have produced an overlay, so an explicit mount_mode = "readonly" on [tools.git] or a global [overlay] default = "readonly" still mounts it read-only rather than being silently overridden. readonly git mode, which pins a read-only bind, was never affected.
  • A file-valued key or [include] target read from a config file inside the main repository's .git is no longer acted on in a readwrite --worktree launch. That tree is bound read-write so commits can land, which makes it sandbox-writable between launches, but it sits outside the project directory and so was missing from the roots devsandbox refuses to take such values from - letting a config rewritten in-sandbox choose which host file the next launch mounted. It is now refused like the project directory, the shared $TMPDIR and the sandbox home. A launch that mounts that tree read-only, whether through readonly git mode or mount_mode = "readonly", still trusts it.
  • Your global git config files are now mounted read-only under every mount mode in readwrite git mode - ~/.gitconfig, ~/.config/git/config and every [include] target. devsandbox resolves those files to decide which host files to mount, so with mount_mode = "readwrite" on [tools.git] (or a global [overlay] default = "readwrite") they were writable host binds: a sandbox could append core.excludesFile = ~/.aws/credentials to your real ~/.gitconfig on one launch and have devsandbox bind that file in - writable - on the next, with both trust checks satisfied. The ignore and attributes files those configs name are not parsed by devsandbox and still follow the mount mode. Only mount_mode = "readwrite" changes behavior here; every other mode already mounted these read-only, so git config --global fails with Device or resource busy as it did before.
  • The resolver that reads your global git config no longer inherits GIT_CONFIG_GLOBAL from the host. That variable replaces the entire global scope, while git inside the sandbox reads the bound ~/.gitconfig regardless - so with it set, devsandbox mounted files named by a config the sandbox never reads. Only hosts that set it are affected.

v0.20.0 - 2026-08-22

Breaking Changes

  • A --no-mitm proxy run with a path- or url-scoped filter rule now aborts instead of starting. Those scopes need a URL, and an HTTPS connection reaches the proxy as CONNECT host:port - so such a rule covered plain HTTP only while reading as though it covered everything. The error names the rule; rewrite it at host scope or keep MITM enabled. Runs with only host-scoped rules, and every run with MITM enabled, do not abort - but note that host-scoped rules now also apply to HTTPS on a --no-mitm run, where they previously did not, so a default_action = "block" allowlist starts refusing HTTPS it used to tunnel. See Filtering without MITM and the corresponding entry under Fixed.

  • A url-scoped filter or log_skip rule written as a regex must now spell the host in lower case, with no trailing dot and no default port. The URL a rule is matched against is canonicalized, which includes dropping the port the scheme implies - so https://api.example.com:443/v1/.* now matches nothing, where before this release it was the only spelling that could match an intercepted HTTPS request. Exact and glob patterns are canonicalized alongside the target and need no change; regexes are left as written, because a URL's path half is case-sensitive. Check any regex rule at url scope, including ones auto-detected as regex by a metacharacter rather than declared with type = "regex". See Host matching is case-insensitive.

Added

  • The repository is now a Claude Code plugin marketplace with two plugins, installed independently: devsandbox-config answers configuration questions from the current documentation instead of a recalled older release, and devsandbox-triage explains a command that failed on a sandbox restriction once, instead of letting the agent retry against the boundary. See Claude Code plugin.
  • New proxy.max_log_body_bytes sets how much of each request and response body the proxy records in its log (default 256 KiB, 0 records none). Bodies still reach their destination whole - only the logged copy is bounded. Entries whose recorded copy was cut carry a *_truncated flag; like every other optional field in a log entry it is absent rather than false when it does not apply, so a parser must read absence as false. See Body Capture Limit.
  • New proxy.redaction.max_scan_bytes sets the largest request body content redaction will hold while scanning it (default 10 MiB). A request past the limit is blocked, since a scan of part of a body proves nothing about the rest; raise the key if a workflow legitimately uploads more than that through the proxy. A project .devsandbox.toml may only lower it; the global config and [[include]] files, which are host-owned, may move it either way. See Redaction Coverage.
  • Your global git ignore and attributes files now reach the sandbox, so git status no longer lists files your core.excludesFile rules cover. In the default readonly git mode both files - whether named by core.excludesFile/core.attributesFile or found at git's default ~/.config/git/ignore and ~/.config/git/attributes - are copied in read-only and the sanitized ~/.gitconfig points at the copies; previously they named host paths the sandbox never had, and git ignores a missing one in silence. A file devsandbox cannot carry in is named in a warning instead of being left as a value git would drop without a word. See Git.

Changed

  • devsandbox tools check no longer reports a missing git config on a host that keeps its git configuration solely at ~/.config/git/config (or $XDG_CONFIG_HOME/git/config). Every file git reads as global-scope configuration now counts, and the message names both locations. In the default readonly git mode it also lists the global ignore and attributes files it will carry into the sandbox, resolved the same way the launch resolves them - so a core.excludesFile set from an [include] is reported at the path it actually names. Those paths appear on their own config: lines, and in --json as config_paths.

Fixed

  • The sanitized ~/.gitconfig and the global ignore and attributes copies now reach the sandbox under the docker and krun backends. Those backends mount the sandbox home at /home/sandboxuser, while the generated files were mounted at the host home path - so git in the guest never saw them, and it reports a missing gitconfig, excludesFile or attributesFile with exit 0 and no warning. The destinations now follow the sandbox home wherever a backend places it, and the core.excludesFile / core.attributesFile values are written ~/-relative so they resolve on every backend. The bwrap backend, which binds the sandbox home at the host home path, is unaffected.
  • A kept Docker container (keep_container) is now recreated when the mounts a tool contributes change, instead of being reused with the set baked in at creation. Volume mounts cannot be added over docker exec, and the reuse check only looked at the tool configuration - so a mount that appeared afterwards was simply absent: the sanitized git ignore and attributes copies never reached a container created before you set core.excludesFile, and a container created by an older devsandbox kept mounting the generated git config at the host home path. Because git ignores a missing excludesFile with exit 0 and no warning, this was silent. The contents of the configs devsandbox generates count too: the sanitized ~/.gitconfig and the ignore and attributes copies are rewritten through a rename on every launch, so changing your host git identity or your global ignore rules put new bytes behind a mount path that never moved - and the container kept serving the file it was created with. Launching from a different terminal window still reuses the container.
  • The sanitized ~/.gitconfig can no longer carry a key outside its allowlist. Values were written into the generated file verbatim, so a user.name containing newlines - which git preserves - opened a section of its own, and everything after it landed inside that section; the same path silently truncated an ordinary name at a # or ;. Values are now written git-quoted, and one carrying a control character is dropped with a warning instead. core.excludesFile and core.attributesFile are also ignored when they are set from an [include] whose target sits somewhere the sandbox can write - the project directory, the shared $TMPDIR, or the sandbox home a previous session wrote - and likewise when the file they name sits there. The value naming a host file to copy in has to come from the host, and so does the file.
  • Your global git ignore and attributes files now reach the sandbox on a host that has no global git config file at all; git honors ~/.config/git/ignore whether or not any config file exists, but devsandbox stopped before carrying it. A copy left by an earlier launch is also removed once the setting behind it is gone, instead of staying mounted indefinitely.
  • A git identity defined through an [include] or [includeIf] block now reaches the sandbox. In the default readonly git mode the sanitized ~/.gitconfig was built from git config --global user.name / user.email and a scan of the top-level [user] section, neither of which expands includes - so a user who keeps a work identity in an [includeIf "gitdir:..."] block, the standard way to separate it from a personal one, got no identity at all inside the sandbox and every commit attempt failed on it. The safe copy is now generated from the fully resolved global configuration, evaluated against the project directory so the conditional branch that matched at launch is the one carried in. The key allowlist is unchanged: credential.helper, alias.*, url.*.insteadOf, http.extraHeader, sendemail.smtpPass, user.signingkey and the include directives themselves are all still dropped. An identity kept only at ~/.config/git/config, with no ~/.gitconfig at all, is now carried in too, where it previously produced no safe config; and editing an included file now takes effect on the next launch instead of leaving the previous identity in place indefinitely. Resolving conditional includes means reading the configuration from inside the repository, which also reads its local config - so a repository whose own .git/config git refuses to read no longer costs you the global one: the configuration is re-read from outside the repository, and the launch warns only when the host actually has an [includeIf] block that could not be evaluated.
  • The sanitized copy of a repository's .git/config can no longer be redirected onto an arbitrary host file. The copy is written into the sandbox home, which is bind-mounted read-write into the sandbox - so a session could leave a symlink where the next launch writes it and have the host follow it, either overwriting the file it named or mounting that file into the next session as the repository's config. The copy is now written through a temporary file and renamed into place, which replaces such a link instead of following it, and it is regenerated on every launch rather than skipped on a timestamp comparison the same link controlled.
  • The sandbox-aware configs devsandbox generates for starship, tmux, powerlevel10k, oh-my-zsh and the desktop portal can no longer be redirected onto an arbitrary host file, the same way the sanitized .git/config could. Each is written into the sandbox home, which is bind-mounted read-write into the sandbox, and none of them is pinned by a mount at its own path - so a session could leave a symlink where the next launch writes it and have the host follow it, truncating the file it named and overwriting it with the generated config. ~/.flatpak-info and the oh-my-zsh plugin file needed no configuration at all to reach; the other three needed the tool's mount_mode set to disabled. All five are now written through a temporary file and renamed into place, which replaces such a link instead of following it.
  • A revdiff review opens again under herdr with revdiff 0.8.23 and later. That release sends the viewer's stderr to a file so a fast-failing revdiff's error text survives the overlay closing, and the launch script shape devsandbox accepts had no place for a redirect - so every launch was refused with launch script does not match the declared pattern and no review ever appeared. The redirect is now accepted in the one spelling the launcher emits, and its target is confined to the shared temp directory exactly like the completion sentinel, since it is the same thing: a file the host opens for writing because sandboxed code named it. See Launch scripts are validated and relocated.
  • A herdr launch script can no longer name a program the validator never checked. A KEY=VAL prefix was accepted in quoted form whether or not the /usr/bin/env that parses it was present - but with no env, the shell treats a quoted 'EDITOR=nvim' as the command word, not an assignment, and runs it: a PATH lookup for a file by that literal name, or a direct exec when the value carries a /, resolving against the project tree that is bind-mounted read-write. The pinned program was never reached, so the argv allowlist decided nothing. A quoted assignment now requires the env prefix, and the bare unquoted form - the only spelling a shell reads as an assignment on its own - is unaffected. The same script's line splitting no longer treats a line of vertical tab, form feed or U+00A0 as blank: the shell's word separators are space, tab and newline only, so such a line is a second command it would look up on PATH, and skipping it hid that statement from the one-statement check while leaving its bytes in what the host runs. See Launch scripts are validated and relocated.
  • devsandbox overlay migrate --apply no longer destroys a host directory when it fails to put a symlink in its place. The destination was cleared first and the link created after, so an interrupt during the recursive delete, or a full or read-only disk at the symlink call, left the host subtree gone with nothing put back - the failure mode the file case was already hardened against. The link is now staged beside its destination and renamed into place, so the delete only runs once the replacement exists, and a symlink replacing a host file is now swapped atomically rather than unlinked first.
  • --rm no longer deletes a --worktree checkout while another session for the same project is still running. The state removal correctly declined and reported it, but the worktree teardown beside it ran first and unconditionally, so git worktree remove --force took the directory and any uncommitted work in it regardless. Both are now driven by the same liveness answer. This also covers a docker or krun launch that lost the primary designation to a live bwrap session, which the previous check could not distinguish from a sole occupant.
  • A remembered ask-mode answer no longer overrides an explicit filter rule. The decision cache was consulted before any rule was evaluated and is keyed on the host alone, so approving one request that matched a narrowly-scoped ask rule and choosing to remember it installed a host-wide allow that short-circuited every later block for the rest of the session - recorded in the log only as cached decision, with no sign a rule had been skipped. A rule that says allow or block now wins outright; a remembered answer still stands in wherever the outcome would otherwise be a prompt.
  • A launch that arrives while --rm is tearing the same sandbox down now waits for it and starts a fresh sandbox, as intended. The wait reopened the lock file on each attempt but could not recreate the directory holding it, and the teardown renames the whole sandbox root away - so every remaining attempt failed identically and the launch died after two seconds naming a missing lock file. The wait is also long enough to outlast the teardown it exists for: a --rm --worktree run holds the sandbox across git worktree remove --force, which on a checkout carrying build artifacts takes longer than the two seconds the retry allowed, so the launch aborted with failed to acquire session lock rather than waiting. And a launch whose lock was taken in the instant between the teardown renaming the sandbox aside and releasing it no longer proceeds against a directory being deleted, which failed moments later naming a missing .primary.lock.
  • devsandbox sandboxes prune now reclaims the disk left behind by an interrupted removal. --rm and prune rename a sandbox aside before deleting it, and every listing path skips what is staged on purpose - so a removal killed partway through stranded the whole tree somewhere sandboxes list, prune and overlay migrate --all-sandboxes all ignored, with nothing able to report or reclaim it. Prune now lists them separately as interrupted removals and deletes them, leaving alone any whose teardown is still running. A sandbox whose own project directory begins with .removing- is also no longer hidden from the CLI entirely: staged trees now live in a directory of their own instead of sharing that namespace with real sandboxes.
  • The proxy.redaction.applied and proxy.credential.injected audit events now spell their host field the same way proxy.filter.decision does. They read the request host directly and left it uncanonicalized, so an intercepted HTTPS request recorded api.example.com:443 on those two events against api.example.com on its filter decision - and any query joining audit events on host, which is what the field is for, silently dropped them.
  • The kitty proxy no longer hangs on a request that asks kitty to withhold its reply. no_response and wait_for_child_to_exit were forwarded unchecked while the proxy waits for exactly one response per request, so either one parked a handler and two host descriptors for the life of the process and made shutdown wait out its drain timeout. Both are now denied - kitty's own client sends them false for every command the proxy supports - and all reads carry a 30 second deadline.
  • devsandbox logs internal --type proxy now reports a record too long to scan instead of silently returning a truncated log. In a compressed rotation the aborted scan also left the rest of the file unread. devsandbox logs proxy --last with a negative value now reports the bad value rather than panicking.
  • The directory herdr launch scripts are relocated to is now checked against the project directory as well as the sandbox home and the shared temp directory. The relocation root sits under ~/.cache, and the project tree is bind-mounted read-write at the path the host knows it by - so running devsandbox from a directory that contains that root (~/.cache itself, say) left every relocated script writable from inside the sandbox, which is exactly the swap-after-validation window relocation exists to close. The check covers a project reached through a symlink too: devsandbox learns the directory as your shell spells it, so a project entered as ~/proj-link while the bind mounts the tree behind it would otherwise show no overlap with a relocation root spelled through the target. Such a session now starts with a warning and denies every herdr script launch instead; the other herdr capabilities are unaffected. See Launch scripts are validated and relocated.
  • A revdiff launch under herdr can no longer destroy a host file by planting a symlink where its completion sentinel is written. Confining the sentinel path to the shared temp directory bounds the name the script uses, not the file the write lands on: that directory is bind-mounted read-write at the same path on both sides, so the sandbox could leave a symlink at <sentinel>.tmp and have the host's own shell follow it - truncating whatever it pointed at, ~/.bashrc included, and writing the exit code there. The relocated script now runs under a shell setting that makes such a redirect refuse an existing path instead of writing through it. The script is also handed to /bin/sh by absolute path rather than to whatever sh the pane's PATH resolves, which a project-local bin directory can supply. See Launch scripts are validated and relocated.
  • EDITOR, VISUAL and the env and sh programs a launch command names are now refused when the host's own PATH resolves them inside the project directory or the shared temp directory. A bare name was accepted on the grounds that the host supplies the location - which stops holding the moment PATH reaches a directory the sandbox writes, and .venv/bin, node_modules/.bin and a bin added by direnv or mise are all inside the project tree, bind-mounted read-write. The sandbox could plant nvim there and name it by the bare spelling the allowlist already accepts. This holds for a value byte-identical to your own host EDITOR/VISUAL too: that spelling is accepted whole where the program it names resolves outside those directories, and refused where it does not, since the setting names a file and which file is decided by the same PATH. A name resolving outside those directories is unaffected; if the host resolves sh inside one of them, revdiff's kitty launch patterns are dropped and every launch is denied. The sh test looks at the directory entry PATH found as well as what it points at, so a bin/sh symlink in the project aimed at the real /bin/sh is refused rather than passing on its target - the terminal opens the link, and the sandbox can repoint it at any time before the launch. The same rule now applies to the revdiff binary the launch patterns pin: an installation resolving inside the project directory or the shared temp directory is refused, since the pin would otherwise name a file whose bytes the sandbox chooses. Both directories are matched under the name your shell gave them and under the path that name resolves to, so a project entered through a symlink is bounded either way - the mount follows the link while devsandbox is told the link's own spelling. See The environment prefix.
  • The proxy log file a rotation hands to its background compressor no longer leaks into processes devsandbox starts while that compression runs. Every other log file devsandbox holds is closed automatically when it starts a process; this one was not, so anything launched during a compression inherited a writable handle on a host log outside its own mount namespace, and a share of the lock guarding it that outlived the compression.
  • Two sessions for the same project no longer lose log entries to each other's rotation. A background compression held its source and its archive only within the session that started it, so the other session's pruning could unlink a half-written archive - leaving the compressor writing to a file nothing could reach and those entries gone - or delete a file a writer had taken over. Both files are now claimed for the whole compression, so at no point does the other session see the file being archived as free. See Log Rotation.
  • devsandbox overlay migrate --all-sandboxes no longer operates on a sandbox that is being deleted. --rm renames a sandbox aside before removing it, and the migration enumerated the sandbox directory raw - so it could read the upper of a sandbox mid-removal, or one an interrupted removal left staged, and promote its contents to the host. It now enumerates sandboxes the same way the rest of the CLI does.
  • The completion sentinel a revdiff launch names is now confined to the shared temp directory devsandbox binds for it. The path was only checked for shape - any absolute path of safe characters passed - while the clause the launcher appends has the host truncate <sentinel>.tmp, write the exit code into it and rename it over <sentinel>. Naming ~/.bashrc or ~/.ssh/authorized_keys therefore destroyed that file from inside the sandbox. The bound is taken from the same function that produces the bind mount, so the two cannot drift, and a launch whose directory cannot be derived is denied rather than left unbounded. See Shared temp directory.
  • devsandbox logs internal --type proxy now shows the proxy's own diagnostic log. It never showed anything: the log was named .log.gz and written as plain text, and the reader opened it with a gzip reader and discarded the resulting error, so the command reported no entries and no problem - including for the DEVSANDBOX_DEBUG lifecycle lines whose own message points you at it. The active file is now .log, rotations are compressed to .log.gz as intended, the reader handles both, and files already on disk under the old name still read. A file that genuinely cannot be read is now named in a warning instead of being skipped silently.
  • devsandbox overlay migrate no longer restores files a later sandbox session deleted when it replaced the directory with a socket or a FIFO. A deletion, an opaque directory and a plain file in a later session all correctly hid the earlier session's contents, but a form the migration cannot promote was skipped without hiding anything - so rm -rf d followed by anything creating a socket at d (a running agent's IPC socket is the case that turns up) put every file under d back on the host. See Migrating overlay data to host.
  • An [[include]] file may again raise proxy.max_log_body_bytes and proxy.redaction.max_scan_bytes. Both keys only tighten, because the project .devsandbox.toml is writable from inside the sandbox - but the clamp was applied to every merged file, so a raise in a host-owned include was dropped with no error, which is a limit the user configured silently failing to apply. The restriction now applies to the project file alone.
  • A kitty launch can no longer be wrapped in a shell the sandbox supplies. Command patterns pin the program they allow to its resolved absolute path, but the wrapping sh in sh -c '<tool> ...' was matched on basename alone - and the shared temp directory is bind-mounted read-write at an identical path on host and sandbox, so an executable planted there and named by absolute path was accepted as the shell and run on the host as the invoking user, with the argv allowlist deciding nothing. Only sh and /bin/sh are accepted now, both resolved by the host itself, and no command pattern falls back to basename matching any more. See Capabilities.
  • kitty @ ls --all-env-vars is now denied. The response filter narrows the window list to windows the sandbox opened, but not what each one reports - so an owned window still returned the environment kitty launched it with, which is the host user's environment rather than the sandbox's. Requires the opt-in list_owned capability to have been reachable at all. See Request options.
  • A herdr request can no longer name one method to the proxy and a different one to herdr. The case-variant key defence added for kitty and for herdr's parameters was missing on the herdr request envelope itself, where the method name lives - so {"method":"pane.send_input", …, "METHOD":"ping"} was filtered as an unconditionally-allowed ping and executed as pane.send_input with the caller's parameters, past every validator, because an approved request is forwarded byte for byte.
  • Two launches started at the same moment for the same project no longer both claim its persistent sandbox state. Whether another session was running was checked well before the session lock was taken, so simultaneous bwrap launches could each conclude they were alone: both then wrote the same overlay upper and work directories instead of the second one running in concurrent mode, which is what left one session's installed tools and caches missing or half-written in the other. The answer now comes from the lock itself, so exactly one launch is primary however they interleave. --rm also no longer deletes sandbox state that another session is still using - the removal is skipped and reported. See Container Persistence.
  • Proxy request logs no longer lose entries once the log directory reaches its five-file limit. The name of a new log file was picked from how many files were present, a number pruning holds at the limit - so from then on each rotation reopened the file it had just handed to the background compressor, which deleted it moments later, and the next rotation wrote over an archive that already existed. Everything logged after that point was lost and older archives were overwritten, which is what left devsandbox logs proxy showing a session's early requests and nothing since. File names now keep counting up instead of being reused, and a rotation skips a name a compression still holds, moving to the next index; shutdown waits for the compressions still running. The proxy's own diagnostic log, which is never compressed, hit the same limit differently: it stopped rotating and grew as one unbounded file. See Log Rotation.
  • devsandbox logs proxy no longer hangs on a damaged log archive. One corrupt record in a rotated .gz file - a write cut short by a kill or a full disk is the usual source - sent the reader into an endless loop at full CPU, so the command printed nothing and had to be interrupted, and every archive after it went unread. Archives are now read a record at a time: a damaged record is skipped and the rest of the file is still shown. Entries carrying a large captured body are also no longer dropped once they pass 64 KiB, which with the default proxy.max_log_body_bytes an ordinary request could reach.
  • devsandbox overlay migrate no longer restores files a later sandbox session deleted, and now plans the same operations on every run. When one session removed a directory an earlier session had written into, that directory's old contents were still listed and copied back to the host, even though the sandbox no longer showed them; the same applied to a directory the sandbox deleted and recreated. Operations were also emitted in randomized order, so a single set of sessions could delete the directory or resurrect its stale contents depending on the run. Entries hidden by a deletion are now dropped with it, and operations are ordered by path, so a directory is always created before anything inside it. Files the host already had in a directory the sandbox emptied and refilled are a separate case: they stay in place and are merged with what is promoted. See Migrating overlay data to host.
  • devsandbox overlay migrate --apply no longer aborts when an entry changed type inside the sandbox. Replacing a host file with a directory, or a directory with a file or symlink, failed with not a directory, file exists or is a directory - an ordinary overlay change, since deleting a name and recreating it as something else is how a file becomes a directory. The migration stopped there with the operations before it already applied, and re-running died at the same place, so the host was left half-migrated with no way to finish. The destination is now replaced, which means an overwrite can remove a host directory when the sandbox has a file or symlink at that name. The default dry-run marks each such line ← replaces a host directory: its contents are deleted and counts them in its summary, so a recursive delete is visible before you pass --apply instead of reading as an ordinary file overwrite. A destination directory the sandbox also has as a directory keeps its existing contents, as before.
  • devsandbox overlay migrate --apply can no longer write outside the host path it was given. A symlink at a destination path was opened and truncated, so the migration rewrote whatever the link pointed at - any file on the host, named nowhere in the preview - and a symlinked directory below the target path was followed the same way. Destinations are now replaced atomically without being opened, leaving the old link's target untouched, and a symlinked parent component aborts the migration naming it. See Safety model.
  • The KEY=VAL prefix a kitty or herdr launch command may carry is now restricted to EDITOR, VISUAL and REVDIFF_EXIT_CODE_ON_ANNOTATIONS, and their values are checked rather than passed through. Any uppercase variable was accepted with an unconstrained value, so an overlay launch could set BASH_ENV or point EDITOR at a binary planted in the shared temp directory - which the host executes as the invoking user when a file is opened in the overlay. EDITOR and VISUAL now accept only a known editor - or whatever your own host EDITOR/VISUAL names, so an unusual one keeps working - given either as a bare name or as an absolute path the host's own PATH lookup yields; relative paths, path arguments, flags that editor does not treat as a plain switch, and shell-significant characters are refused. Naming the program alone was not enough: the file the editor opens lives in the project tree, which is bind-mounted read-write, so EDITOR=sh would have made the sandbox's own file the program. The path is compared literally rather than resolved, so a symlink in the shared temp directory pointing at the real editor cannot be repointed after the check. The env program is pinned to /usr/bin/env or the host's own resolution of it instead of matching any path ending in /env. See The environment prefix.
  • A kitty ls response the proxy cannot filter is now refused instead of being passed to the sandbox unchanged. The filter narrows the window list to the windows the sandbox itself opened; when parsing failed it logged the error and forwarded the upstream reply anyway, which is every OS window, tab, title, working directory, foreground command line and per-window environment on the host - the opposite of what the list_owned capability promises. See Request options.
  • The kitty proxy now checks the whole request, not just the command line. kitty @ launch carries around forty further options and only --type and the argv were ever read - the rest of the request was forwarded to the host socket unchanged, so sandboxed code could pair an allowed command with --env, --watcher, --copy-cmdline or --stdin-source and run its own program on the host, or read another window's screen contents. Every option is now decoded and vetted, --cwd and --match are pinned to values devsandbox resolves on the host, and an option the proxy does not model is denied rather than passed through. The same applies to send-text --all and --match-tab, which reached past the owned window, and to encrypted or password-authenticated requests, which hid the real command from the filter. --marker and --logo are denied with the same group: a function marker spec has kitty run the named Python file, and both take an absolute path verbatim - including one in the temp directory shared read-write with the host. send-text --session-id is denied alongside --all and --match-tab. Option lists come from kitty 0.46.2; a newer kitty that adds one denies the launch naming the unknown field. See Request options.
  • A kitty or herdr request can no longer smuggle a second value past the field checks by spelling a key in a different case. The proxy matched JSON field names case-insensitively and kept the last of several matches, while kitty and herdr read the key they name and treat the spellings as distinct - so {"args": ["/bin/sh", …], "ARGS": ["/usr/bin/revdiff"]} was validated as the second list and forwarded as the first, since an approved request goes to the host socket byte for byte. That defeated the argv allowlist and every pinned option, and the same trick on the envelope's cmd made the response filter treat a host-wide window dump as a launch. Keys are now matched exactly: a case variant, a repeated key, or anything after the request object is a denial.
  • devsandbox overlay migrate no longer stalls or dies on a socket or FIFO left in a sandbox overlay. ~/.claude/channels/matrix/mux.sock is the usual one: reading a socket aborted the apply partway through and every retry died at the same entry, and a FIFO hung the command with no output at all. Both are skipped, along with device nodes - they carry no meaning on the host.
  • Sandbox state is no longer wiped from under a running session when the launch that started first has already exited. Owning the sandbox was decided by a lock the primary session drops on exit, so a launch arriving after that took ownership even though the earlier concurrent session was still running - deleting its overlay directories on startup and writing the layer it had mounted. Ownership now also requires that no other session holds the sandbox at all. A launch that arrives while --rm is tearing the sandbox down waits for it instead of failing outright. See Container Persistence.
  • Two sessions for the same project no longer overwrite each other's proxy request log. Both write into the log directory the project's sandbox root gives them, and each appended to whichever file was newest - so each counted only its own bytes toward the rotation size, and the first to rotate handed the shared file to the background compressor, which deleted it while the other session was still writing to it. Everything that session logged from then on was lost with every write still reporting success. A writer now holds the file it is using, and the other starts its own; a file a live session holds is also left out of pruning.
  • devsandbox logs proxy no longer drops the rest of a file when one record is too long for it. A record past the reader's 8 MiB limit ended the scan, and everything logged after it went unread in silence - which read as an archive that simply held fewer requests. Such a record is now stepped over, counted and reported, and the entries after it are still shown. Two ways of producing one are closed as well: proxy.max_log_body_bytes is capped at 2 MiB, and the headers a log entry records are now bounded at 64 KiB per request and per response, marked req_headers_truncated / resp_headers_truncated when anything was dropped. The sandbox picks which host a request reaches, so an upstream it controls could answer with megabytes of headers - enough to make a record the reader refused, and to push real entries out of the rotation.
  • A large or endless upload from inside the sandbox no longer exhausts host memory or hangs the proxy on its way to being logged. Every request was logged before the filter decided on it, and the log captured the whole body with no limit and no deadline - so a request the filter was going to block could still spend unbounded host memory first, and a body that never finished sending held the connection's handler forever. The proxy now records at most proxy.max_log_body_bytes and gives up on a stalled body after 5 seconds; entries cut short are marked req_body_truncated / resp_body_truncated so a truncated body is not read as a short one. Content redaction, which is off by default, reads an allowed request's body in full because scanning it for secrets requires holding all of it - now bounded by proxy.redaction.max_scan_bytes and a 30 second deadline, past which the request is blocked rather than forwarded unscanned.
  • Filter rules now apply to HTTPS when MITM is disabled. --no-mitm tunneled every CONNECT without consulting the filter, so a default_action = "block" allowlist let all HTTPS through while startup warned only that filtering was "limited to host-level matching" - host-level matching was not happening either. Host-scoped rules are now evaluated per tunnel, a blocked one gets 403, and ask mode prompts per tunnel with the same per-host decision cache. The startup notice about it is now informational rather than a warning, so a --no-mitm filter run no longer pauses for confirmation - every rule that survives startup is enforced in full. See Filtering without MITM.
  • devsandbox logs proxy now records one entry per HTTPS CONNECT in transparent mode, with the host and the filter decision. Previously the only trace was an audit event emitted once per host per session, so repeat connections and their decisions were invisible.
  • A forwarded port that fails to connect now reports why reliably. The helper process's error output was read while it was still being written, so the message explaining the failure could come back truncated or garbled.
  • Proxy host rules now apply to every spelling of the name they cover. A rule for blocked.example.com used to miss BLOCKED.EXAMPLE.COM and blocked.example.com., which reach the same server, so a block rule could be bypassed by changing the case of a request. Host-scoped exact and glob patterns and the request host are both lowercased and stripped of a trailing dot before matching, and a host-scoped regex is compiled case-insensitively - lowercasing a regex would change what its character classes and anchors mean, but leaving it case-sensitive against an always-lowercase target would have silently retired any host regex written with uppercase. Add (?-i) inside the pattern if you want a case-sensitive host regex. url-scoped rules canonicalize the host half of the URL the same way and leave the path alone, since a URL's path is case-sensitive and its authority is not. The same canonicalization applies to log_skip rules, credential injector host patterns, and the ask-mode decision cache - approving a host once now covers its other spellings instead of asking again. See Scopes.
  • devsandbox overlay migrate --apply no longer deletes a host directory when it cannot write the file meant to replace it. The destination was cleared before the replacement was read, so an unreadable, swapped or oversized source - all things a sandbox-writable overlay can produce between the preview and the apply - left the host directory gone with nothing put back, on the first run and identically on every retry. The replacement is now staged in full first and the destination cleared only once it exists.
  • devsandbox overlay migrate no longer restores files hidden by a directory a later session replaced with a file or a symlink. Deletions and emptied directories were both handled, but a directory replaced by a non-directory was not, so the earlier session's entries were still listed and the apply then died at not a directory with earlier operations committed, failing identically on every re-run.
  • overlay migrate --set-mode no longer writes through a symlink at .devsandbox.toml. The project directory is bind-mounted read-write and the protective bind over that file is only applied when it already exists at launch, so a project without one let the sandbox create the name as a link to any host file, which a later --set-mode would read, rewrite as TOML and hand back - skipping the confirmation prompt entirely when only one config was affected. A non-regular file is now refused, and the write goes to the name itself.
  • A filter rule with action = "ask" is now actually asked. The approval queue was built only when default_action was ask, so such a rule under any other default let the request through unprompted while recording the decision as ask in the request log - an audit trail asserting an approval nobody was shown. Note the consequence: matches of such a rule now need a monitor, and a request nobody answers - or one that arrives with no monitor connected - is blocked. The startup banner advertises devsandbox proxy monitor only for default_action = "ask", so start it yourself whenever any rule carries action = "ask". See Ask Mode.
  • Host-scoped filter rules now match the host a request is actually sent to. Under MITM the decision was made against the request's own Host: header while the connection went to the CONNECT target the sandbox named, so a request to a blocked host passed every host rule simply by writing an allowed name in the header - and one ask-mode approval, cached under that same name, was reusable against any destination. Both now read the connection target, as do the ask prompt and the audit event.
  • url-scoped filter rules now match intercepted HTTPS. The URL the proxy builds for such a request always carries the port (https://api.example.com:443/v1), so a rule written the way the documentation writes them matched nothing at all - a url-scoped block rule was a silent no-op for every HTTPS request. A port the scheme already implies is now dropped from both the rule and the request. A url rule whose pattern spelled its host with any uppercase was inert for the same reason - only the request side was being lowercased - and both sides are now canonicalized together. See Scopes.
  • A kitty launch can no longer end at a host shell. --hold was classified with the cosmetic options, but kitty runs the user's shell in the window once the launched command exits - so an otherwise-allowed launch could reach an interactive shell in a window the sandbox then owns, with the argv allowlist deciding nothing. It is denied alongside --hold-after-ssh, which is the same thing for one command. --color is restricted to the inline name=value form, because a spec without = is a path kitty opens and parses - a host file read the sandbox names, which is what --logo was already refused for. See Request options.
  • A command a kitty or herdr launch names can no longer reach a binary the sandbox planted by routing an absolute path through ... Every check that pins a program to its resolved host path collapsed .. textually before comparing, while the kernel expands each symlink on the way - so a link in the shared temp directory, which is mounted read-write at an identical path on both sides, made /…/tmp/<id>/link/../../../usr/bin/nano read as /usr/bin/nano to the filter and resolve to the sandbox's own file on the host. The same applied to the env program and to an EDITOR/VISUAL path. A path that is not already in canonical form is now refused rather than cleaned. Two quoted argv tokens written with no space between them are refused for the same class of reason: the shell joins them into one word, so the argv that was checked was not the argv that ran.
  • EDITOR and VISUAL may now carry option flags, so EDITOR="code --wait", subl -w and emacsclient -t no longer deny the launch outright. Any value with a space was refused, which is the stock setting for every editor that has to be told to block. The program is still held to the same allowlist, and each flag to the words that editor is known to treat as a self-contained switch: a charset check would have accepted nvim -u, vim -S, emacs -l, hx -c or kak -e, each of which turns the file the editor opens - a file in the read-write project tree - into a program the host runs. A value byte-identical to your own host EDITOR/VISUAL is accepted whole, flags included, as long as the program it names does not resolve inside a directory the sandbox writes. See The environment prefix.
  • devsandbox logs proxy no longer reports a corrupt archive as an empty one. A .gz file that is not gzip at all - a truncated or overwritten rotation is the usual source - produced no entries, no error and no warning, so a file whose records were unreachable read exactly like a session that made no requests. Damage met before any record has been recovered is now reported; a malformed tail after real records still reads as the normal end of an archive a killed writer left.
  • overlay migrate --set-mode no longer hangs on a FIFO at .devsandbox.toml, and no longer widens the file's permissions. The refusal of a non-regular file could not be reached for a FIFO, because opening one blocks until a writer appears - so the command produced no output at all rather than the error it was supposed to give. Rewriting a setting also reset the file to 0644, so a config deliberately kept at 0600 became world-readable; an existing file now keeps its own mode. --apply gained the same FIFO guard for a source file swapped between the preview and the apply.
  • devsandbox overlay migrate no longer spends minutes building its plan for a large overlay. The time it took grew with the square of the overlay's size, so a cache directory with a hundred thousand entries left the default dry run producing no output for minutes. The plan it produces is unchanged.
  • --rm no longer lets a launch started moments later run against a sandbox that is still being deleted. The removal held the sandbox lock but deleted the lock file along with everything else, and a waiting launch recreates that file - so it took a hold on nothing, was designated the sandbox's owner, and started up against a tree disappearing underneath it. The state is now renamed aside before it is deleted, so the waiting launch sees no sandbox at all rather than half of one.
  • A blocked plain-HTTP request is no longer written to the proxy request log twice, or counted twice in the request total the session.end audit event reports.
  • A project .devsandbox.toml can no longer raise proxy.max_log_body_bytes, or set it to 0 to stop bodies being recorded at all. That file is writable from inside the sandbox, so both directions were the sandbox choosing how much of its own traffic is kept - the same reason proxy.redaction.max_scan_bytes already only tightened. A project file may still lower the limit.
  • A --isolation docker or --isolation krun launch no longer deletes a running bwrap session's overlay directories. Stale-session cleanup was gated on a flag only bwrap launches ever set, so a container launch read itself as the sandbox's sole occupant and removed every session directory under a sandbox home it shares with the live session by project name. It now consults the session designation itself.
  • Sandbox-chosen argv can no longer forge lines in the kitty proxy's log. Denied and allowed launches recorded the program and arguments unquoted into a newline-terminated log, so a newline in argv wrote whole records of its own - including counterfeit allow entries - in the audit trail of the component deciding what runs on the host.
  • The documentation no longer presents .env hiding as something the sandbox always does. It is on by default and can be switched off with hide_env_files = false or --no-hide-env, but neither appeared anywhere outside the config file devsandbox config init generates - so the Security Model table read as a guarantee next to rows that were marked configurable, and a reader auditing secret handling from the docs had no reason to check a shared config or a teammate's alias for a run that exposes those files. The row is now marked configurable, the [sandbox] reference documents the key, and --verbose - undocumented in the same sweep - is described with the wrapper log it controls. See Environment Files and Sandbox Settings.

v0.19.0 - 2026-08-13

Breaking Changes

  • In [tools.<name>] and [proxy.credentials.<name>], a value of the wrong type now fails the load instead of being ignored. [tools.docker] enabled = "true" used to leave the tool off with nothing said, so the sandbox ran without something the config plainly asked for. Fix the value or drop the key - quoted booleans are the common case. devsandbox doctor reports the same error in its config row. See Values of the Wrong Type.

Added

  • An OOM-killed sandbox is now reported instead of just disappearing: a message on stderr, a sandbox.oom audit event, and an oom-killed or oom-kills(N) status in devsandbox sandboxes list. Nothing to configure. Always active on docker and krun; on bwrap it needs [sandbox.resources] limits, which is what gives the sandbox a cgroup of its own - without them a SIGKILL exit is reported without claiming the OOM killer caused it. See OOM Tracking.
  • Config keys devsandbox does not recognize are now reported on stderr under their full dotted path, so keep_containers instead of keep_container no longer leaves the setting quietly at its default. Covers the global config, includes, .devsandbox.toml, and the [tools.<name>] and [proxy.credentials.<name>] sections - mount_mode is now recognized only for a tool that contributes mounts, so [tools.docker] and [tools.go] report it. The keys stay ignored, but the launch pauses for the warning to be confirmed. See Unrecognized Keys.

Changed

  • A launch that raised warnings now reprints them as one block and asks Start the sandbox anyway? [y/N] instead of starting with them unread. --yes skips the prompt, and a launch without a terminal on both stdin and stderr prints the block and continues, so a scripted run cannot block on a question nobody sees. See Startup Warnings.
  • The .devsandbox.toml trust prompt now shows only the settings devsandbox recognizes, so an untrusted project cannot pad the approval screen with comments and invented keys that do nothing. Trust still covers the whole file by hash. A .devsandbox.toml that is not valid TOML is now an error rather than something to approve. See Local Config Files.
  • The sandbox's $TMPDIR is now cleaned up instead of growing forever. Launching empties it when no other session for the project is live, and otherwise removes only entries untouched for 7 days. Applies whenever a tool needs a host-readable temp directory - revdiff is the only built-in case. See Shared temp directory.
  • That directory moved from ~/.cache/devsandbox/revdiff-ipc/<session>/ to ~/.cache/devsandbox/tmp/<session>/ and is no longer owned by the revdiff tool. The old one is removed automatically on the next launch; nothing needs reconfiguring.
  • Embedded pasta upgraded to passt 2026_07_28.f8df3f1 (from 2026_07_16.090d739).

Fixed

  • A launch that redirects stderr no longer hangs on a prompt the user cannot see. devsandbox claude 2>run.log wrote the .devsandbox.toml trust question or the mise trust question into the log file and then waited on it forever. Prompts now need a terminal on both stdin and stderr, and otherwise take their non-interactive path: the local config is skipped, and mise trust is left to mise trust.
  • Answering an interactive prompt no longer swallows input typed after the answer. The .devsandbox.toml trust prompt and the startup warning prompt read one line without buffering ahead, so the workload still receives its first input.
  • Fixed a plain bwrap launch logging session.start with no matching session.end, which left every such session looking unfinished in the audit trail. Exit status and signal propagation are unchanged. See Resource Limits.

v0.18.0 - 2026-07-24

Breaking Changes

  • bwrap proxy mode now requires iproute2 and nft or iptables on the host, and its sandboxes are IPv4-only. A --proxy launch on a host missing them, or missing the nf_tables/ip_tables and nf_conntrack kernel modules, now aborts naming what is missing instead of starting with egress open. There is no opt-out. devsandbox doctor reports the same check as the proxy: firewall row. Non-proxy launches are unaffected. See Requirements.
  • bwrap proxy mode now also requires a pasta that supports --map-host-loopback. That option maps the proxy gateway, which is the lockdown's only permitted destination, so on an older pasta every connection would hang until it timed out. The launch is now refused up front. The embedded pasta always supports it, so this is reachable only with use_embedded = false or a failed extraction.
  • The container image no longer bakes in gh (GitHub CLI) or neovim; the image is slimmer. Rebuild with --build-arg INSTALL_GH=true / INSTALL_NVIM=true, or add them in a derived image. Their host configs are still mounted as before. The Dockerfile also gained SANDBOX_HOME, SANDBOX_UID, SANDBOX_GID and NODE_VERSION build args.

Added

  • The standalone GitHub Copilot CLI (npm @github/copilot) now works sandboxed. Its home ~/.copilot - config, MCP servers, sessions and auth - is mounted read-write and persistent, so a sandboxed copilot runs authenticated and copilot --resume / --continue finds sessions from an earlier run. The older gh copilot extension keeps its existing ~/.config/github-copilot and ~/.cache/github-copilot mounts. See Tools: GitHub Copilot.

  • New rtk tool, so the rtk CLI proxy keeps its configuration and history inside the sandbox. Your filters and config.toml now apply in the sandbox, and the tracking database behind rtk gain accumulates across runs instead of being recreated empty on every launch. The host's database is never modified. See Tools: rtk CLI Proxy.

  • herdr terminal workspace support, through a capability-filtering proxy. Running devsandbox inside a herdr session lets sandboxed tools open a review overlay in a herdr tab - revdiff is the first consumer. The host control socket is never bind-mounted; a proxy is, and it permits only the methods enabled tools declare, denying the rest of the 86 herdr exposes. Configure with [tools.herdr] mode = "auto" | "disabled" | "enforce". See herdr Terminal Workspace.
  • herdr can now capture and restore a sandboxed agent's native session. A new agent_reporting capability permits three reporting methods and nothing else, so herdr can record an agent's own session ID or transcript path and resume it after a restart. It activates only for a direct devsandbox claude|pi|codex launch inside a herdr pane, because that is the only case where devsandbox knows both anchors every report is checked against; starting a shell and typing claude inside it does not enable it. Behavior change: such a launch previously started no proxy and now starts the filtered one, and a herdr control socket that cannot be reached is no longer fatal in the default auto mode. See Agent session capture and restore.
  • Launching an agent from a herdr pane now records which sandbox owns that pane, so a resume cannot silently start a new session. run-agent refuses a resume-shaped invocation unless re-entry would reach the same project directory and the same sandbox state root. A --worktree launch is the case that makes this necessary: its state root is derived from the repo root while the session runs in the worktree, so re-entering would open a different session store and begin a fresh session while appearing to resume.
  • New devsandbox agent-wrappers activate <shell>, so supported agents run sandboxed by default. It prints shell functions for you to evaluate from your own startup file, the way mise activate does - devsandbox agent-wrappers activate fish | source, or eval "$(devsandbox agent-wrappers activate bash)". Typing claude then runs devsandbox claude in the current directory with every argument passed through, while claude-no-ds and command claude still reach the real binary. claude, pi, codex, opencode, and copilot (the standalone GitHub Copilot CLI) are wrapped when installed. Nothing is written to disk and no startup file is ever edited. Because the definitions are regenerated at every shell start they cannot go stale: a newly installed agent, or an upgrade that moved the devsandbox binary, is picked up by the next shell. Nothing is wrapped inside a sandbox. The feature is independent of herdr and useful on its own. See Tools: Shell wrappers.
  • The wrappers never resolve devsandbox through PATH. Each definition carries the absolute path activate resolved for itself, and a path that has disappeared fails closed with devsandbox: no executable at <path> and exit 127 rather than falling through to the unwrapped agent or looking devsandbox up in PATH, which may name a directory sandboxed code can write to. Only the once-per-shell-start activate call itself goes through PATH, so an upgrade that moves the binary self-heals.
  • New devsandbox run-agent <agent> [args...] command, the entrypoint for the wrappers. It forwards every argument untouched, so run-agent claude --resume ID reaches the agent instead of being parsed as a devsandbox flag, and it executes the real agent directly when already inside a sandbox so a wrapper visible in-sandbox cannot recurse.
  • Resource limits are now available on every isolation backend, under a backend-neutral [sandbox.resources] section. memory, cpus and the new pids are configured in one place instead of the docker-scoped block they used to live in. Limits are opt-in, and one that cannot be enforced aborts the launch rather than silently running unlimited. See Resource Limits.
  • The bwrap backend can now cap memory, CPU and process count. A runaway build or fork bomb previously consumed host resources unchecked on the default Linux backend. The sandbox now runs inside a systemd transient scope carrying the equivalent cgroup v2 controls, which requires cgroup v2 and a systemd user session with the needed controllers delegated. memory bounds resident memory but not swap, which is weaker than docker and krun. See Sandboxing: Resource Limits.
  • The docker backend now honors pids as --pids-limit, so a fork bomb inside the sandbox cannot exhaust the host's PID space. The first run after upgrading recreates any kept container once. krun skips the flag and warns: a host-side cap would limit the VMM's own threads, not the guest's processes.
  • The container backends (docker and krun) now make host-installed mise tools available inside the sandbox on Linux hosts. The host's ~/.local/share/mise/installs is mounted read-only and mirrored into the sandbox mise data dir on startup, so your host toolchain resolves in-guest without reinstalling or network access - previously the main reason a mise-centric workflow felt unusable under krun. Versions installed inside the sandbox still take precedence. Host tools compiled against a newer glibc than the guest image may not run there, and macOS hosts share nothing. See Tools: mise.
  • New [tools.mise] ignore_global_config option to stop the sandbox reading the host's global mise config. A large host config full of @latest specs made every shell start resolve them over the network, which on a proxy or egress-locked sandbox hangs and can OOM the guest. Setting it points MISE_GLOBAL_CONFIG_FILE at /dev/null in the sandbox; the project .mise.toml, the image's system config and settings.toml still apply. Defaults to false.
  • Experimental krun microVM isolation backend. --isolation krun runs the same sandbox image inside a libkrun microVM, so the workload gets its own guest kernel behind a hardware virtualization boundary - the right boundary for genuinely untrusted code, where a host-kernel exploit must not reach the host. It is opt-in, never auto-selected, ephemeral, and runs rootless. Requires podman, a crun built with libkrun, and /dev/kvm on Linux or Apple Silicon on macOS; devsandbox fails fast with installation guidance when a prerequisite is missing. See krun microVM backend and the getting-started guide.
  • Egress lockdown: in proxy mode krun installs a host-side, deny-by-default firewall in the VMM's network namespace, permitting only loopback, established traffic and TCP to the proxy port - so the LAN, cloud metadata at 169.254.169.254, external DNS and every non-proxy gateway port are closed without being enumerated. It runs host-side because under libkrun TSI the guest has no routable interface. The in-guest shim waits for it before running anything, so untrusted code never runs with egress open, and any failure tears the microVM down. See krun backend.
  • Guest tooling: an egress-locked guest runs mise offline, so @latest specs resolve instantly from seeded host installs instead of stalling on lookups that never traverse the proxy. krun runs no boot-time install pass, so a tool your .mise.toml pins that is neither seeded nor already present must be installed in-sandbox with MISE_OFFLINE=0 mise install.
  • Overlay copies: krun copies overlay and tmpoverlay tool directories into the guest, since it cannot mount kernel overlayfs over virtio-fs. Sockets, FIFOs and device nodes in the source are skipped as the runtime artifacts they are, instead of aborting the launch.
  • Management parity: devsandbox doctor reports krun prerequisites as advisory rows with remediation, sandboxes list and sandboxes prune cover ephemeral krun sandboxes, and forward is best-effort. A second launch while a session is already active fails fast with a copy-pasteable podman rm -f, instead of aborting only after the image was rebuilt.

Changed

  • bwrap proxy mode is now enforced, not best-effort. The old route surgery discarded its own errors and exec'd the workload regardless, so any host where ip is not on the user's PATH started with egress fully open and nothing reported it. Even where it applied, the sandbox's own subnet stayed reachable (deleting the default route does not remove the on-link route), so the router UI, a NAS, the LAN DNS resolver and a cloud host's 169.254.169.254 metadata endpoint were all still directly reachable, along with every host loopback port and all of IPv6. A proxy-mode sandbox now gets the same deny-by-default lockdown krun applies, installed before the workload exists, and any failing step aborts the launch. This scopes the sandbox's path to the proxy, not its reachable destinations - those are still decided by HTTP filtering, so a metadata or LAN address is refused as a direct socket and still served through the proxy, where it is visible and refusable. Configured outbound port-forwarding rules keep working; host loopback ports that were not configured as one are no longer reachable at the gateway. See Backend-specific behavior.
  • devsandbox doctor checks the proxy firewall prerequisite for every backend, and checks it by using it. The old krun: firewall row only looked for an nft or iptables binary, and its name told bwrap users - the default backend, which now also aborts a --proxy launch without a working firewall - that it did not concern them. The new top-level proxy: firewall row applies the real lockdown rule set in a throwaway namespace, so a host with nftables installed but no loadable nf_conntrack is reported here instead of failing mid-launch. It stays advisory.
  • Resource limits moved to a backend-neutral [sandbox.resources] section, and gained a pids limit. [sandbox.docker.resources] still works but is deprecated, and it stays scoped to the container backends, which merge it field by field with the new section. bwrap does not read the deprecated block at all: it never honored it, and since it aborts when a limit cannot be enforced, applying a docker-oriented config there would turn a working setup into a failed startup on any host without a systemd user session. Validation errors now name the block they came from. See Resource Limits.
  • devsandbox doctor no longer closes with "All checks passed!" when rows warned. A run with no failures but one or more warnings now ends with All required checks passed (N advisory warning(s)), and the "How to fix" block sits directly above that line instead of being separated from it by the tools table. The exit code is unchanged - only errors fail the run.
  • Embedded pasta upgraded to passt 2026_07_16.090d739 (from 2026_06_11.a9c61ff). Embedded bwrap is unchanged at v0.11.2.
  • krun warns when building a project-provided Dockerfile, whose RUN steps execute on the host - outside the microVM boundary and outside the proxy egress lockdown. The build still proceeds; the auto-generated default and every docker-backend build stay silent. See Build-time trust boundary.
  • Docker and krun startup timeouts now report the container's logs instead of a bare deadline. The in-guest shim's fatal output went to the container log and never reached the user, so a launch that failed a second into setup surfaced only as container setup timed out after 90s. The last 50 log lines are now appended, best-effort and never masking the original timeout.
  • Proxy-mode, content-redaction and .env masking claims now state their actual limits wherever they appear. --help, the config template, the README, the landing page and the guides all described proxy mode as routing or logging all HTTP(S) traffic and .env masking as unconditional. Neither holds: Docker is env-var routing with no network-level enforcement, and .env masking scans 3 directory levels below the project root, skipping node_modules, .git, vendor and .venv. Content redaction only sees requests that reach the proxy, and HTTPS bodies, headers and URLs only when MITM is enabled. See Redaction Coverage. Documentation only.

Fixed

  • Proxy mode no longer leaves the host's own loopback services directly reachable from the sandbox. pasta forwards host ports into the namespace unless told otherwise, and loopback is the one interface the egress firewall must permit - so a local database or dev API stayed reachable at 127.0.0.1:<port> even though the same service was correctly refused at the proxy gateway. Proxy-mode launches on bwrap and krun now disable automatic forwarding for every protocol without a configured outbound port forwarding rule.
  • Claude and Pi sessions started inside the sandbox are no longer discarded on exit. Their persistent session overlays were skipped when the host directory did not exist - exactly the case for a user who authenticated on the host and only ever runs the agent sandboxed - so every transcript vanished with the sandbox and herdr would resume into nothing. devsandbox now creates those directories on the host when the agent's own directory exists. CLAUDE_CONFIG_DIR and PI_CODING_AGENT_DIR are honored.
  • claude -c / claude --continue are now covered by the herdr worktree guard. The guard recognized only herdr's own resume argv, but --continue reopens the same per-project session store --resume does - so in a pane launched with --worktree it silently began a new conversation.
  • A herdr pane record that cannot be parsed now names its file. The failure refuses every resume in that pane, and deleting the file is the only way out - which the old error gave no way to find.
  • Codex sessions started inside the sandbox are no longer discarded on exit, so codex resume can find them. All of ~/.codex was mounted as a config directory and got a tmpoverlay, so every rollout file vanished with the sandbox and a later resume reported no rollout found for thread id. ~/.codex/sessions is now bound separately with a persistent overlay while the Codex home keeps its tmpoverlay, and devsandbox creates the directory on the host when it is missing. CODEX_HOME is honored.
  • revdiff can now actually open its review overlay in a herdr tab. The launcher shell-quotes the generated script path, sending sh '<path>', but the proxy recognized only the unquoted form - so every launch was denied and the launcher reported only herdr pane run failed. The proxy now strips one layer of single quotes before validating the path; a remainder still containing a quote is left alone and still rejected.
  • The docker backend no longer aborts startup with fork overlay child: operation not permitted. Realizing a tmpoverlay directory required namespace flags that Docker's default seccomp profile denies without CAP_SYS_ADMIN, so effectively every isolation = "docker" sandbox failed to start. Those directories now use the same copy-on-start path macOS and krun already used, so no capability or seccomp relaxation is needed and a previous run's writes are still never visible.
  • The krun: system pasta doctor row says when its warning may be a false positive. The probe only searches $PATH, while podman also looks in helper_binaries_dir, so a host that installs pasta there was told to install what it already has. The remediation now names the limitation and gives the command that settles it.
  • The kitty proxy no longer accepts a launch command that names a sandbox-planted binary. Launch patterns matched argv[0] on basename alone, and the revdiff IPC directory is a write-through bind mounted at an identical path on the host - so sandboxed code could drop its own executable there, name it in a kitty @ launch request, and have kitty run it on the host as the host user. Patterns are now pinned to the program's resolved absolute path, and an unresolvable binary denies every launch rather than falling back.
  • A second session for the same project no longer breaks a running session's notifications, Docker access and kitty remote control. The portal, Docker and kitty sockets were keyed only on the project, so a second session unlinked the live session's socket and its exit deleted the path outright. Each session's sockets now live in a directory private to the owning process; DOCKER_HOST and KITTY_LISTEN_ON point at $HOME/.run/<pid>/.
  • A socket path too long for the kernel is now reported as such. bind(2) rejects anything past 107 bytes with a bare invalid argument, which the portal surfaced only as an opaque timeout. The portal, Docker, kitty and herdr proxies now report the path, its length, the limit and the remedy.
  • Proxy-mode sandboxes no longer stall for minutes resolving @latest mise tool specs. Some backends' lookups never traverse the proxy and hang to their 20s timeout, and mise re-resolves per listed row - a single mise ls was measured at 14 minutes. All proxy-mode sandboxes now bound remote lookups at 3s, and a value you set through the sandbox env config takes precedence. Under krun the offline-mise layer removes the lookups entirely: the same mise ls went to under a second.
  • tmpoverlay config dirs that degrade to a copy-on-start overlay (krun anywhere, docker on macOS) are now reset to the host source on every run. The copy only wrote source entries and never removed extraneous ones, so anything a previous - possibly untrusted - run left under the target survived into the next session, defeating tmpoverlay's discard-on-exit promise. The clear is mount-aware, preserving nested read-only bindings, and it removes rather than follows any symlink planted at the target or an intermediate path component.
  • Docker isolation no longer hangs at startup for non-root users when a tool uses an overlay mount. CAP_DAC_OVERRIDE was granted only to krun, so under rootful Docker container-root could not read the host-owned overlay manifest and exited before signalling ready - the launch then waited out the 90s readiness timeout. It is now granted on docker too, scoped to the shim's root setup phase; the workload never holds it.
  • A kept Docker container is now actually reused when a tool uses an overlay mount, instead of being destroyed and rebuilt on every launch. The overlay manifest was a per-run temp file that the container binds permanently, so every later start mounted a path that no longer existed and fell back to recreating the container - keep_container bought nothing and all container state was lost each run. The manifest now lives at a stable per-project path and is rewritten in place.
  • A container whose startup fails now reports the failure immediately, instead of after the 90s readiness timeout. The readiness probe only polled for the shim's ready sentinel, which fails identically for a container that is still booting and one that has already died. It now also checks container state and reports the exit code alongside the log tail.
  • Piped stdin now reaches non-interactive krun and docker commands. The container ran without -i, so data | devsandbox --isolation krun - tool closed the workload's stdin and silently lost the input. bwrap, which runs the workload as a direct child, was unaffected.
  • A sandboxed command's exit code now propagates to the host instead of collapsing to 1. devsandbox - sh -c 'exit 42' exited 1 on every backend, because a non-zero command result was treated as a generic CLI error - which also printed a spurious Error: line. Genuine setup failures still exit 1 loudly, and a container-engine launch failure (exit 125) is surfaced as an error rather than passed off as the workload's own status.
  • The session and proxy lock files are no longer unlinked on release, and are opened with O_NOFOLLOW. Unlinking reopened a split-lock race in which two holders could run at once, and the predictable temp path let a co-tenant pre-plant a symlink for the holder to truncate.
  • The in-guest shim no longer silently discards sandbox user/group creation errors, and USER names the real account. A failed useradd/groupadd was ignored, and USER was hardcoded to sandboxuser even when no such passwd entry existed. USER is now resolved from the passwd entry for the uid the shim drops to.
  • The container backends no longer reinstall the pre-baked node on every guest, and krun now persists mise-installed tools across runs like docker already did. The image's node@22 lives in the ephemeral image path, so without seeding it into the persistent data dir every fresh guest stalled for minutes reinstalling it, with node is not a valid shim errors alongside a large global mise config. The seed is version-level, so a node version the project installs itself still persists to the sandbox home.
  • krun now refuses to launch on an Intel Mac instead of failing obscurely later. There is no supported libkrun path on darwin/amd64, but the prerequisite check had no architecture probe, so the launch only broke after the image build with an opaque runtime error. The check now runs first and points at --isolation=docker; devsandbox doctor reports it as a krun: platform row.
  • devsandbox config init no longer emits obsolete config keys. The template documented an [overlay] enabled switch that no longer exists and emitted [tools.mise] writable/persistent, which mise no longer reads - so copying the template's own suggestions produced settings that silently did nothing. A round-trip test now asserts every key the generator emits is one the loader recognizes.
  • A hidden mount rule that hides nothing is now reported at launch, and its remediation names something that actually works. A pattern that resolves to a directory cannot be replaced by /dev/null, and the skip was only written to the log file - so pattern = "secrets/**" gave a fully readable secrets directory with nothing on the terminal to say so. The skip is now a startup warning, and every surface gives the only remedy there is: match the files inside the directory (**/secrets/**).
  • devsandbox doctor no longer fails the run because past sandbox runs logged errors. The logs row escalated to an error above 10 errors in 24h, so doctor exited 1 with "Please install missing dependencies" on a host where nothing was missing. Recent log errors say nothing about whether a sandbox can launch now, so the row is advisory at any count - which makes doctor usable as a CI gate again. The failure summary now names the rows that failed instead of always blaming missing dependencies.
  • The hidden mount mode is now documented as files-only, and the README no longer claims the sandbox exposes "nothing else". The config template and the schema comment both described hidden as hiding a file or a directory, while the builder only overlays files. The README's isolation summary also omitted the read-only host system paths and sanitized configs the defaults mount; what is not mounted - SSH keys, cloud credentials, sibling projects - is unchanged. Documentation only.

v0.17.3 - 2026-06-24

Added

  • DEVSANDBOX_DEBUG=1 proxy lifecycle tracing. The MITM proxy now logs a per-request CONNECT / request / response trace to the internal proxy log (devsandbox logs internal --type proxy), including response status, content-type, streaming detection, and time-to-headers. Query strings are stripped so tokens are never logged. Use it to pinpoint where a hung or timed-out request stalls. See Debugging the Request/Response Lifecycle.

Fixed

  • MITM proxy no longer buffers response bodies before relaying headers. goproxy relays a response to the client only after the OnResponse handler returns and does not flush the body until the handler-supplied resp.Body is read, but request logging read the entire body with io.ReadAll to capture it. For any streaming response the body stays open until generation finishes, so the proxy withheld the response headers for the full duration - codex aborted with Codex SSE response headers timed out after 20000ms while the proxy spent 10-80s reading the stream (one HTTP upgrade buffered for 82s). Crucially these responses are not always identifiable by Content-Type (codex's streamed responses carry an empty Content-Type), so media-type sniffing alone could not avoid the buffering. The response body is now wrapped so it streams to the client unchanged while a bounded prefix (256 KiB) is captured for logging; the log entry is written when the body closes. The proxy never buffers a body before relaying headers, so SSE, chunked, empty-Content-Type, and large responses all stream incrementally. This also unbreaks WebSocket (WSS) and other HTTP upgrades through MITM: 1xx/101 Switching Protocols responses are now left untouched, so goproxy can type-assert resp.Body to io.ReadWriter and relay the upgraded connection (the old code read the 101 body and replaced it with a bytes.Reader, stalling the stream for up to 82s and failing the relay).

v0.17.2 - 2026-06-15

Changed

  • Embedded pasta upgraded to passt 2026_06_11.a9c61ff (from 2026_05_07.1afd4ed). The statically linked pasta binary that backs sandbox networking is rebuilt from the newer upstream passt release. Embedded bwrap is unchanged at v0.11.2.

v0.17.1 - 2026-05-13

Fixed

  • Proxy no longer panics on requests with a nil URL. goproxy can dispatch HTTPS requests whose http.Request.URL is nil when its internal url.Parse fallback fails (the parse error is swallowed and the request is still handed off). Every downstream step - credential injection, filtering, redaction, ask-mode, request logging - dereferences req.URL, so any such request crashed the proxy worker. RequestLogger.LogRequest now falls back to RequestURI when URL is nil, and the request handler short-circuits with a 403 (malformed request: missing URL) instead of dispatching downstream.

v0.17.0 - 2026-04-30

Added

  • Audit-grade structured logging. Per-session fields (session_id, sandbox_name, sandbox_path, project_dir, isolator, pid, devsandbox_version) on every dispatched entry, plus synthesized session.start / session.end lifecycle events and security events (proxy.filter.decision, proxy.redaction.applied, proxy.credential.injected, proxy.mitm.bypass, mount.decision, notice.overflow). See Audit Logging.
  • OTLP header_sources. Resolve receiver headers from value / env / file at runtime so secrets stay on the host. See Authenticating to an Auth-Enforced Endpoint.
  • NODE_USE_ENV_PROXY=1 is now set automatically in proxy mode so Node.js ≥24's built-in fetch (undici) honors HTTP(S)_PROXY - fixes ENETUNREACH from npx-based tools like mcp-remote.

v0.16.0 - 2026-04-29

Added

  • Proxy log_skip rules. Drop matching requests from the proxy log (local + remote dispatchers); the request itself still passes through. See Skipping Log Entries.

v0.15.0 - 2026-04-29

Added

  • devsandbox sandboxes prune --orphaned flag to restrict pruning to orphaned sandboxes (those whose original project directory no longer exists). The flag intersects with other selectors: --orphaned --older-than 30d removes orphans last used over 30 days ago; --orphaned --keep N prunes orphans outside the N most-recently-used set; --orphaned --all (or --orphaned alone) removes every orphan. Without the flag, the existing default (orphans-only when no other selector is set) is unchanged.
  • Generic credential injector for proxy. Define credential injection by host + header + value_format + [...source] + overwrite in TOML - no Go code change required to add a new service. Built-in github preset preserves existing config compatibility ([proxy.credentials.github] enabled = true works unchanged, including GITHUB_TOKENGH_TOKEN fallback). Specificity-based ordering when multiple injectors could match the same request (exact host > longer literal > shorter glob, tie-break by name). BuildCredentialInjectors now returns an error for invalid configs (unknown preset, missing host/header, invalid glob).

v0.14.1 - 2026-04-28

Changed

  • zellij tool is now disabled by default. Unlike kitty, the zellij socket has no capability filtering - exposing it lets sandboxed code drive the host multiplexer (run commands in any pane, read pane contents, etc.). Auto-detection of an active ZELLIJ session no longer mounts the socket or forwards ZELLIJ* env vars on its own. Set [tools.zellij] enabled = true to opt back in. devsandbox tools check zellij reports the opt-in requirement.

v0.14.0 - 2026-04-27

Added

  • codex, opencode, and pi tools now honor their respective custom config-location env vars on the host and forward them into the sandbox so the CLIs resolve the same paths inside:
  • codex: CODEX_HOME overrides ~/.codex. When set, the host value is passed through and the directory is mounted at the same path.
  • opencode: OPENCODE_CONFIG_DIR is mounted in addition to (not in place of) ~/.config/opencode, matching opencode's load semantics; the env var is forwarded.
  • pi: PI_CODING_AGENT_DIR overrides ~/.pi/agent. The agent dir is still tmpoverlayed (settings/credentials are write-discarded) and the sessions/ subdirectory is still persisted; the env var is forwarded.

v0.13.3 - 2026-04-20

Fixed

  • kitty proxy revdiff launch pattern now accepts the unquoted /usr/bin/env prefix the launcher actually emits (only ENV_PREFIX assignments and the inner argv are single-quoted). The literal absolute path is required - bare env (PATH-relative) still rejects, so $PATH shadowing can't be used to bypass the inner-program check.

v0.13.2 - 2026-04-20

Fixed

  • kitty proxy revdiff launch pattern: added MatchShellExecEnvSentinel, accepting sh -c "'/usr/bin/env' 'KEY=VAL' ... '<prog>' '<arg>'...; touch '<sentinel>'". The revdiff launcher injects an env wrapper so the kitty-spawned overlay inherits EDITOR/VISUAL from the caller's login shell; the previous pattern matched only the no-env form. Env-var names are restricted to ^[A-Z_][A-Z0-9_]*$, the inner argv is still validated against the existing revdiff pattern, and the sentinel-tail rules (no shell metacharacters, canonical path) are unchanged.

v0.13.1 - 2026-04-18

Fixed

  • revdiff tool no longer wipes its shared IPC directory on Start/Stop. Because the dir is exported as $TMPDIR for every sandboxed process, long-lived tenants (Claude Code's per-session task cache under $TMPDIR/claude-<uid>/…/tasks/, Node's compile cache, Go's build cache) populate subtrees that must survive sandbox restarts for the same project - and parallel sandboxes on the same project share the directory, so wiping it from one tore state out from under the others. The old RemoveAll on Start could yank state out from under a running caller; Node's non-recursive fs.mkdirSync then failed with ENOENT, breaking every subsequent Claude Code Bash tool call. Start now only ensures the dir exists (0700); Stop is a no-op. Stale revdiff sentinels are harmless - the launcher uses mktemp with fresh names.

v0.13.0 - 2026-04-17

Added

  • [sandbox.environment.<NAME>] config block: declare sandbox environment variables using the same source model as proxy credentials (value / env / file, priority value > env > file). env = "X" with X unset on the host silently skips the variable; an unreadable file = "..." is a startup error. Declaring the same variable in both env_passthrough and environment fails at startup with a message naming the variable - each variable belongs in exactly one place.
  • pi tool: integrates Pi Coding Agent. ~/.pi/agent is mounted with credential protection; ~/.pi/agent/sessions persists across runs.
  • [proxy.credentials.github] overwrite = true: force-replace any existing Authorization header on outgoing api.github.com requests. Intended for the pattern where a sandboxed CLI (e.g. gh) refuses to start without a token in its environment - pass a placeholder through env_passthrough / sandbox.environment while the real token stays on the host and is swapped in by the proxy. Default remains false (existing tool-set headers are preserved).
  • revdiff tool now provides a shared IPC directory (~/.cache/devsandbox/revdiff-ipc/<session>/) bind-mounted at the same path on both sides and exported as TMPDIR. The kitty-spawned overlay shell runs on the host and receives sentinel/output paths as literal strings, so host and sandbox must agree on the string - argv-shipped paths need Source == Dest equality, not just a shared inode.

Changed

  • Kitty tool now runs a capability-filtering proxy instead of bind-mounting the host socket. The host kitty remote-control socket is no longer exposed inside the sandbox; a local proxy at $HOME/.kitty.sock is exposed instead, and KITTY_LISTEN_ON is rewritten to point at it. Sandboxed processes can only issue kitty commands declared as capabilities by an enabled tool (launch_overlay, launch_window, launch_tab, launch_os_window, close_owned, wait_owned, focus_owned, send_text_owned, get_text_owned, set_title_owned, list_owned), and *_owned commands are scoped by ownership tracking to windows the sandbox itself opened. Shell metacharacters in sh -c payloads for launch_* are rejected outright. remote_control_password is unsupported - use allow_remote_control = socket-only. New [tools.kitty] fields: mode (auto default / disabled / enforce) and extra_capabilities (additive; launch_* entries rejected). Under auto, the proxy only starts when at least one enabled tool declares a capability - zero attack surface when no tool needs kitty. revdiff is the built-in consumer.

Fixed

  • macOS: shortened test directory names to stay under the platform's unix socket path length limit (affected kittyproxy and kitty tool tests).

v0.12.0 - 2026-04-16

Changed

  • Wrapper diagnostic output (port-forward notices, session warnings, proxy setup info, container progress) no longer writes directly to stderr while a child process owns the terminal. Messages are written to $XDG_STATE_HOME/devsandbox/wrapper.log (or ~/.local/state/devsandbox/wrapper.log) and a one-line banner is shown on exit if anything was suppressed. This prevents wrapper output from corrupting TUI applications (Claude Code, aider, etc.) running inside the sandbox. Pass --verbose or set DEVSANDBOX_DEBUG=1 to restore the old behavior of writing every message to stderr.

Added

  • --worktree and --worktree-base flags: opt-in git-worktree mode. Bare --worktree auto-generates devsandbox/<session-or-timestamp> off HEAD; --worktree=<branch> reuses or creates a named branch. The sandbox CWD is the worktree; the main checkout is untouched. With --git-mode=readwrite, commits land on the worktree branch only. --rm removes the worktree on exit via git worktree remove --force + prune. --worktree + --git-mode=disabled is rejected at flag-parse time. Worktrees live at ~/.local/share/devsandbox/<project-slug>/worktrees/<branch>/ and the slug is derived from the main repo root so sibling worktrees share sandbox state. devsandbox sandboxes prune and the doctor command are worktree-aware.

Fixed

  • zellij and kitty tool socket bindings are now explicit bind mounts (Type: MountBind) instead of inheriting the default tmpoverlay from CategoryRuntime. Overlayfs cannot expose a unix socket from its lower layer, so under the previous policy the host socket was invisible inside the sandbox and zellij list-sessions / kitten @ silently failed.
  • Auto port-forwarding no longer tries (and fails) to forward when the sandbox shares the host network namespace. Without proxy mode the sandbox uses bwrap's --share-net, so a tool listener inside the sandbox is the same kernel socket as the "host" bind the forwarder would attempt - producing a spurious bind: address already in use error for every detected port. Auto-detect now inspects the sandbox netns inode and skips forwarding (with a one-line explanatory message) when it matches the host; the sandbox ports are already directly reachable on 127.0.0.1. For the rare case where auto-forward runs in a properly isolated netns but the host happens to already have that port in use, the forwarder falls back to an ephemeral host port and logs the mapping instead of silently dropping the service.

v0.11.0 - 2026-04-14

Added

  • zellij tool forwards an active Zellij session into the sandbox by mounting the session socket directory and the zellij binary. Auto-detected when ZELLIJ is set and the binary is on PATH, so zellij commands run inside the sandbox attach to the host multiplexer.
  • zellij tool now also mounts $XDG_RUNTIME_DIR/zellij/, which is where zellij 0.41+ stores its IPC socket (the legacy /tmp/zellij-$UID/ holds only cache/log files on modern releases). The override env var is ZELLIJ_SOCKET_DIR (previously the tool checked the incorrect ZELLIJ_SOCK_DIR).

v0.10.0 - 2026-04-10

Added

  • kitty tool forwards the Kitty remote-control socket into the sandbox so kitten @ commands inside the sandbox can drive the host terminal.

v0.9.3 - 2026-04-08

  • ~/.local/bin and ~/.local/share/claude are now read-only bind mounts instead of persistent writable overlays. Under the split-mode default introduced in v0.8.0 these host-managed tool-install directories were being treated as CategoryData, which let in-sandbox tool self-updaters (e.g. Claude Code's own updater) write partial/empty files into the per-project overlay upper-dir. Those writes shadowed the real host binaries in every subsequent session, causing failures like fish: '/home/$USER/.local/bin/claude' exists but is not an executable file (exit 126).

v0.9.2 - 2026-04-08

Fixed

  • HTTP proxy no longer intercepts the body of HEAD requests. The previous behavior broke Content-Length handling and caused errors for some clients (e.g. Helm pulling OCI charts).

v0.9.1 - 2026-04-07

Fixed

  • Sandbox removal now chmods files recursively before deletion. Go populates its build cache with 0500 files, which previously caused sandbox cleanup to fail.

v0.9.0 - 2026-04-07

Added

  • devsandbox scratchpad [name] [command...] subcommand for running sandboxes in managed, clean working directories under ~/.local/share/devsandbox-scratchpads/. State persists between runs. Name defaults to default.
  • devsandbox scratchpad list and devsandbox scratchpad list --json list scratchpads with size and state info.
  • devsandbox scratchpad rm <name> (with --all, --keep-state, --force) removes scratchpads and their sandbox state.

Fixed

  • Git tool now strips sensitive fields from .git/config in place instead of replacing the file wholesale. The previous full replacement caused the git CLI to refuse to operate even for read-only commands inside the sandbox.

v0.8.2 - 2026-04-06

Fixed

  • Claude tool stores project knowledge under the data section so chat history persists between sandbox runs.

v0.8.1 - 2026-04-06

Added

  • macOS support for the devsandbox shim via a platform-specific copy-on-start overlay implementation, split from the Linux path.
  • jq is now included in the default Docker image.

Changed

  • Debian base image bumped in the Docker image.
  • mise-managed tool dependencies bumped.
  • Docker and lint CI workflows limit concurrency to avoid redundant runs.

Fixed

  • Restored shim source files that were missing from the v0.8.0 release and added CI coverage so the shim is built and verified on every run.

v0.8.0 - 2026-04-05

Breaking Changes

  • [overlay] enabled removed - replaced by [overlay] default which accepts: split (default), overlay, tmpoverlay, readonly, readwrite.
  • [tools.mise] writable and persistent removed - use [tools.mise] mount_mode instead. Mise no longer has tool-specific overlay configuration; use the unified mount_mode system.
  • Default mount behavior changed - tool mounts now default to split overlay policy (configs → tmpoverlay, caches/data/state → persistent overlay) instead of read-only bind mounts. This prevents supply chain attacks from poisoning host tool configurations through sandboxed package managers.

Migration Guide

Before After
[overlay] enabled = true [overlay] default = "split" (or omit - it's the default)
[overlay] enabled = false [overlay] default = "readonly"
[tools.mise] writable = true, persistent = true [tools.mise] mount_mode = "overlay"
[tools.mise] writable = true, persistent = false [tools.mise] mount_mode = "tmpoverlay"
(no equivalent) [tools.git] mount_mode = "readwrite"

Added

  • Binding categories - tools now classify each mount as config, cache, data, state, or runtime, enabling differentiated overlay policies.
  • [overlay] default - global mount mode for all tool bindings with five modes: split, overlay, tmpoverlay, readonly, readwrite.
  • Per-tool mount_mode - override the global default for specific tools (e.g., [tools.git] mount_mode = "readwrite"). Accepts disabled to prevent a tool's config from being mounted entirely.

Changed

  • Tool bindings no longer hardcode ReadOnly or Type - the builder resolves these based on the mount mode policy chain (per-tool > global > split).
  • Claude, Copilot, Codex, and OpenCode tools previously mounted configs read-write; they now follow the global mount mode (default: tmpoverlay for configs).