CMS plugins with no direct writes to the live site: WASM sandboxes plus reviewable changesets
A WordPress plugin is PHP in core's process with the whole database. A Hatamoto plugin runs in a WASM sandbox and writes content only through a changeset the owner or a standing approval publishes.
A Hatamoto plugin runs in a WebAssembly sandbox and has no host function that writes to the live site. It stages its output in a changeset during a run; the owner previews the site with that changeset applied, then publishes it, discards it, or later reverts it as a whole. Only a standing approval the owner granted lets a background run publish typed field updates without that review.
Today, in this post, I describe the general architecture of the hatamoto plugin, how it works and why the two room implementation puts security first.
A WordPress plugin is PHP loaded into the same process as core on every request, with the global $wpdb, the filesystem and outbound network. After close to twenty years of running sites that way, with no sandbox to switch on, this is the mechanism I built that is better than status quo.
The changeset model is merged to main and runs on hatamoto.dev; it will reach self-hosted installs in core 1.1.0 with SDK 0.17, which will be released in the next 48 hours.
What a WordPress plugin can reach
In the developer reference, $wpdb can read any table in the WordPress database, its insert, update and delete methods write to whatever table the call names, and query runs any SQL, DROP included. $wpdb->users and the options table are tables like any other. The same PHP opens files, calls any server and creates users or changes roles.
There is no permission list. wp-config.php is loaded on every page view not served from a cache, and an operator can add constants there to block external URL requests or disable the plugin file editor.
That openness is why WordPress was so easy to extend. It is also where the risk sits. In Patchstack's 2026 report, 46% of the vulnerabilities disclosed in 2025 had no fix at disclosure, for the most heavily exploited ones the weighted median time from disclosure to exploitation was five hours, and broken access control was the most exploited class.
Four incidents from wordpress with their CVE
In June 2024 five plugins on WordPress.org shipped updates with injected code that tried to create an administrator account and send its details to an attacker's server. The update inherited every write the plugin already had. A Hatamoto plugin has no function that creates users or touches authentication, so the same code could not create an account, any content it staged would sit in a changeset devoid of approval, and a version change voids its standing approvals. It would still keep the plugin's other grants, such as reads and http.fetch to its listed hosts. Hardening the supply chain itself is left to a later ADR and will be tackled seperately.
In August 2024 Patchstack published CVE-2024-28000 in LiteSpeed Cache, on more than 5 million sites: a weak hash in its user simulation feature could be brute-forced, after which an unauthenticated visitor could create an administrator. Hatamoto has no host function that grants a role, so a bug in plugin code cannot be turned into an administrator account; at worst it exposes what that plugin was granted.
In February 2024 Wordfence disclosed CVE-2024-1071, an unauthenticated SQL injection in Ultimate Member, on 200,000 sites, which could extract password hashes. A Hatamoto plugin granted db gets its own Postgres role that owns one schema, with no privilege on core tables; an injection in plugin SQL lands in the plugin's own tables.
In September 2020 the File Manager plugin, 700,000 installs, shipped a library connector that let unauthenticated visitors upload files and run commands. A Hatamoto plugin has no filesystem: nothing is mounted, and no host function takes a path. The one file a run can read is the owner's attached upload, through upload.read.
What the sandbox enforces
In Hatamoto, plugins are Wasm modules run with Extism on wazero. Besides Extism's built-ins there is one host import, hm_call, and every function behind it is permission checked except the run functions, which are checked against the run's phase. Nothing is granted by default, and a call without its grant returns capability_denied.
limits.memory_pages caps each linear memory at 256 pages (16 MiB) by default. Every call has a deadline, by default 250 ms for filters and template functions and 10 s for actions, cron and routes. wazero has no fuel metering, so the deadline is the mechanism. limits.max_instances caps live instances at 4 by default and 16 at most.
Hooks fail open. A filter that traps, runs out of memory or times out leaves the value as it was, and the instance is discarded. Three in a row switch that hook off for 30 seconds.
The only network is http.fetch, run by the host (media.import_url downloads through the same client). The host resolves the name, refuses private, loopback, link-local, CGNAT and reserved answers, dials the checked IP so DNS cannot rebind, and repeats that on up to 5 redirect hops.
One run from fetch to publish
The owner starts a run from the admin with the plugin's inputs, the hosts it may fetch from and, if needed, an uploaded file. The host then calls on_run_step repeatedly with { "run_id", "phase", "cursor" }; each call has its own deadline, 10 s by default.

The fetch phase has the network and no site data: http.fetch to the hosts the owner confirmed (a cron-started run gets the manifest's allowed_hosts, never *), upload.read, buffer.put and media.import_url. The transform phase has site data and no network: buffer.get, buffer.list, site.info, published reads, entities.slug_taken and changeset.*. Anything off the phase's list returns phase_denied, and the plugin's own kv and db are denied in both.
The phases share only the run's buffer (at most 64 MiB and 10,000 keys), and it flows one way, so transform cannot leave site data for a later fetch phase to send out. A plugin that can read drafts and reach the network can send one to the other, and inside a run the two phases close that path. Outside a run, hooks and cron keep the read functions and http.fetch to the manifest's allowed_hosts, so there the owner's grants are the limit.
Everything transform stages lands in the changeset: entity upserts and deletes, terms, bylines, menus, redirects, media and proposals. The owner previews the site with it applied, in a sandboxed frame that runs no scripts, then publishes it in one database transaction or discards it. One left for 72 hours expires, and expiry, discard and run failure delete it with its buffer and staged media.
Ownership and the risk ladder
Every entity, term, menu, redirect and media item carries an origin, owner or plugin:<name>. A changeset may update or delete only objects of its own origin, matched by source_key; anything else returns not_owned. An edit from the admin, the API or MCP to the body, slug, parent or an SEO field hands the entity to the owner. Each staged update records its base version; if the live object moved meanwhile, that item is listed as a conflict and skipped.
Plain text, titles and alts are approved as a group. A new external host in a link, a changed link target or query parameter, an embed, hidden control characters, a redirect, a slug change or a delete is ticked one by one (a redirect to a page in the same changeset is decided with that page). Site settings, URL patterns, the home page and menu locations can only be proposed through changeset.propose, and each proposal is signed off alone with step-up verification. Until every itemised change and proposal has a decision, the changeset cannot be published. In WordPress, settings such as the site address are rows in the options table, one $wpdb call away.
Background updates without a review each time
A plugin can keep a value in its own kv or db, refresh it from cron and show it through a template function; core escapes that as plain text. Or it declares a standing approval with fields, schemas and max_per_hour (200 at most), which the owner approves once after a step-up sign-in. A cron-started run under it publishes itself only when every item updates a listed field on an object the plugin owns and the field is a number, boolean, date or enum. Any change to the plugin's version, manifest or a named schema voids the approval.
What it costs and what it leaves out
Plugin authors have: no direct writes, an importer split into two phases, state between steps in a 4 KiB run.checkpoint or the buffer, and no kv or db for the whole run. entity.before_save filters do not run on changeset content, and imports take effect only after a person reviews them.
A Go guest costs about 0.5 ms per hook call; a first compile takes about a second per module and a cached load about 50 ms. A render.page round trip on the default slim payload is 480 µs at p50 for 4 blocks and 810 µs for 12.
None of this helps against a bug in core hatamoto itself: the changeset is published by core's own entity service, so a flaw there reaches changeset content as it reaches admin edits. It does not stop an owner approving a bad changeset; the preview and the itemised list only make that less likely. A plugin granted site.scripts runs its JavaScript on every public page with the visitor's privileges, and that grant is the boundary. REST API keys and the MCP server write directly by design, with tokens the owner can revoke.
From a security standpoint, this is what is a better structure for overall data and system integrity and protection and hatamoto adoption will tell. If you haven't tried it give it a shot.