What a plugin upgrade is allowed to destroy

Plugin packages, plugin data and sandbox caches have different lifetimes. How an installer applies digests, atomic renames and restore rules to each one.

November 20266 min readPlatformsSecurityVerification

Small chalk arcs and a rust fleck sit high on deep olive above a heavily reworked dried-rose plane with ridged edges.

The simplest way to install a plugin is to unpack it into a directory and let it run from there. The plugin writes its state file next to its code, the next version unpacks over the top, and for a while nothing goes wrong. But that directory now holds two kinds of file with unrelated lifetimes: release artifacts that could be fetched again at any moment, and the only copy of something the plugin has built up for its user. An upgrade that replaces the directory destroys the second kind. An upgrade that carefully merges into it can no longer tell you which version of the code you are running.

The Agent Plugins specification separates the two with a pair of directories. PLUGIN_ROOT holds the files the package supplied. PLUGIN_DATA holds what the plugin wants to keep, and a host MUST preserve its contents across plugin updates and MAY delete it when the plugin is uninstalled. When I built plugin installation for the agent runtime I work on, in August 2026 and with coding agents doing part of the typing, I treated that split as the design rather than as a naming convention. Later that month the same runtime’s sandbox cache added a third category, for files that a replaced sandbox would like to have back but can live without. Each of the three takes its rules from one question: what does it cost to lose this?

The package costs nothing to lose

Package content is the easy case, because it can be made exactly reproducible. A package arrives as a bounded payload of paths and contents, and before anything touches the disk it is validated and copied into private buffers, so the bytes that were checked are the bytes that get written. Validation refuses absolute paths, backslashes, NUL bytes, . and .. segments and a path that is both a file and the parent of another file, because each of those can reach outside the root, can mean different things on different systems, or describes a tree that cannot exist. It also refuses text with unpaired surrogates and base64 that does not survive a round trip, because both leave room for one payload to mean different bytes depending on who decodes it, and a digest over ambiguous bytes proves very little. Packages over 512 files, 1 MiB per file or 8 MiB in total are refused as well. The installer then computes a SHA-256 digest over the sorted paths, lengths and bytes instead of trusting the number the payload came with, and that digest names the directory the package will live in. A new version does not modify an old one. It has a different digest, so it gets a different root.

Publishing the root follows a recipe much older than agent plugins: write somewhere private, check the result, rename. The installer creates a sibling staging directory with mode 0700 and writes each file with O_CREAT|O_EXCL|O_NOFOLLOW, so it can neither overwrite nor follow anything that appeared in the meantime. It verifies the finished tree against the digest, removes write permission from the files and then from the directories, deepest first, and renames the whole tree to its final path, where it checks it once more. Anything reading that path sees a complete, frozen tree or no tree at all. A failed write is cleaned up on the way out, and two installers racing for one digest settle it at the rename, where the loser verifies the winner’s tree instead of assuming the winner did the job properly.

A directory that already has the right name gets the same suspicion, because a name is only a claim. Reusing it means walking the tree and insisting on no symlinks, a link count of one so that no other path reaches the same inode, O_NOFOLLOW on every open, fstat before and after every read, and finally the digest itself. One test edits a file inside an existing root and expects the install to fail with tree digest mismatch, with the tampered bytes left where they are. Executable bits follow the same logic: they come from the package’s own MCP configuration, never from whoever invoked the install, so one digest always produces the same modes.

The reward for this strictness is that a package root does not need to survive anything. A reconciler can fetch the package, check its identity and digest against what was meant to be installed, and build the root again. Old roots are just as expendable. Pruning them is a separate step that refuses any name it does not recognize and never enters the data directory, and if it fails, the error is logged, a later pass tries again, and the new root is used regardless.

The data is the only copy

PLUGIN_DATA sits entirely outside that tree and its path has nothing to do with the digest, so publishing a new package root does not replace it. The test that pins down the upgrade contract is short enough to describe in a sentence: install a version, leave a state.json in its data directory and a stray file in its root, install the next version, and check that the new version has its own digest root, that state.json is still there, and that the old root is untouched, stray file included. What removes data is an uninstall, through a cleanup record that asks for it explicitly and applies to one installation generation.

Two things are easy to assume about this layout and neither is true. The first is durability. The promise is about plugin updates, and a directory that survives an update does not thereby survive the replacement of the sandbox it sits in. That takes storage that outlives the sandbox, which no directory layout can supply by itself. The second is containment. The specification is blunt on this point: its rules “govern access to files supplied by the plugin package. They do not sandbox a plugin subprocess.” A read-only root blocks ordinary writes, and later verification detects changed package bytes, but the process still runs with whatever authority it was given. In the same way, a matching digest shows that these are the bytes that were asked for and says nothing about who wrote them.

What an installer can police is what the package declares. A server command can name a bare executable or a ./ path to a packaged file; that relative path is resolved inside the package. Its working directory must stay under PLUGIN_ROOT or PLUGIN_DATA. The environment block cannot redefine the reserved names or set any of fifteen blocked process variables or anything in the DYLD_ family, and placeholders expand exactly once, as the specification requires. The test for that last rule contains a small piece of malice: it installs under a directory whose own name includes the literal text ${PLUGIN_DATA}, which a second expansion pass would mangle.

The cache has to earn its place

The third category appears once sandboxes get replaced. A new sandbox starts cold, and some of what the old one accumulated is worth bringing back purely to save time. This is where a small mistake does the most damage, because a restore puts old files into a new environment, so the rules run the opposite way from the rules for data. Restoring is off unless someone turns it on. Cold startup is the default, and build trees, tool homes, credentials and configuration are “never selected implicitly.” What can be selected is a fixed list of nine managed cache paths, because “arbitrary paths are not authority,” and the archive carries a SHA-256 for every entry and runs under limits on bytes, expanded bytes, member count and time.

The detail I would borrow first is a state called uncertain. If the terminal receipt for a snapshot never arrives, the record says uncertain rather than reporting a successful cold start. Since a cold start is always safe, it is tempting to file every unclear outcome under cold and move on. That would make the record claim something nobody knows, and the next person debugging a strange sandbox would believe it. A cache handled this way is not a backup, and nothing in the design offers it a way to become one: its absence may only ever cost time.

The failures this layout prevents are mostly classification mistakes: a plugin keeping its state beside its code, a restore carrying credentials into a new sandbox, a cache that something has started to depend on. When a file turns up that does not obviously belong anywhere, I would ask what happens if it vanishes between two turns. If the answer is that we fetch it again, it belongs in the package. If the answer is that we wait a little longer, it is a cache. If the answer is anything else, it is data, and it needs to live somewhere that outlives whatever you are about to replace.

Chris Chabot, November 2026