CHANGELOG.md in the repo.
2026-08-19
Midplane is on npm, so
npx is now the shortest way in. Until now the engine ran only from docker run or a repo clone. An MCP client config is now the whole install — npx -y midplane server --stdio with a DATABASE_URL, and the safe default (reads allowed, writes and DDL denied, everything audited) needs nothing else. See the standalone engine.The package is a single ~80 KB bundle with no install scripts and no native modules. It’s deliberately not minified — this is a security tool, and anyone evaluating what npx midplane does should be able to read the artifact — and its runtime dependencies stay external rather than vendored, so npm audit and Dependabot can still see them. Releases publish with provenance; 0.19.0 itself carries none, because the publish that creates a package can’t come from CI. Midplane is also listed in the official MCP registry as ai.midplane/midplane.midplane server takes --stdio and --http, overriding MIDPLANE_TRANSPORT — a client config is a command line plus an env block, and the command comes first.DB_PATH defaults to ~/.midplane/audit.db outside a container (/data/audit.db inside one, unchanged). /data isn’t writable on a developer’s machine, so the old default made a local run fail at boot on the audit log. Read the log back with npx midplane audit denies.Two fixes worth calling out: midplane server could run every query twice when bundled (two servers came up on one stdio pipe, each answering every request — correct-looking results, two audit trails, two executions of every allowed write), and midplane query --stdio was broken in the compiled binary. Both are resolved.The engine now runs on Node 22.16+ or 24+ as well as Bun 1.3+. The Docker image is unchanged, and both artifacts build from the same source at the same version.2026-08-17
Denial messages stop pointing at the policy file. A denial is read by an agent, which can’t edit policy — and naming the lever invited pulling it: agents reported the block, then offered to go find and edit
MIDPLANE_POLICY_FILE, which on Cloud is generated from the database and mounted read-only. Every message now states the decision and stops. Advice the agent can act on stays: the whole-table guardrail still asks for a WHERE clause, because that’s a rewrite the agent performs.The two table_access messages instead spell out the resolved level (“resolves to read, which permits reads only”) and close by scoping the denial to the operation — “Another write to users will be denied the same way” — so an agent stops retrying INSERT variants against a table that denies every one of them, without being talked out of the SELECT that would work.policy_rule is unchanged and no allow/deny outcome moves, so anything branching on the wire-level identifier is unaffected. See denial reasons.2026-08-13
Write rules are stated per statement class. Row changes, whole-table writes, and schema changes each get their own independent refuse / ask / allow, replacing one guardrail flag per destructive class plus a single global approvals switch. Holding schema changes no longer forces you to hold every
UPDATE.A new guardrail, guardrails.block_dml, refuses ordinary row changes — and with them CREATE TABLE, CREATE TABLE AS, CREATE INDEX, and SELECT … INTO, which materialize data just as a row change does. It defaults off, unlike the two destructive flags: refusing row changes is a deliberate lockdown, not a safety net.A statement carrying more than one class is held if any of its classes is held, and refuse still outranks ask — a refused statement never reaches a reviewer.See what a write may do and write approvals.2026-08-07
Write approvals — hold an agent’s write until a human says yes. A policy can now answer ask where it previously only answered allow or refuse: a write the policy already permits is held, a reviewer decides, and the agent collects the result by re-running the identical statement. The grant is bound to the exact
(database, sql, intent, token) and is single-use, so an approval for DELETE … WHERE id < 100 can’t authorize … < 100000, and two retries racing one approval yield exactly one execution.Approvals sit under the policy — the stage runs only after it allows, so no approval can buy a way past a denial. A new check_approval tool lets an agent poll a held write without consuming the grant. And neither non-decision is recorded as a denial: a pending request or an unreachable approval service reaches the agent as retryable and writes no DECIDED row, so a control-plane blip never puts a refusal nobody made into a compliance export.See write approvals.2026-07-22
Self-signed and private-CA Postgres connect on
sslmode=require. The engine now follows libpq’s TLS semantics instead of node-postgres’s stricter default: sslmode=require (and prefer) encrypts without verifying the certificate, so a database presenting a self-signed or private-CA cert connects rather than failing with an opaque self signed certificate the moment an agent runs its first query. Supplying a root CA (sslrootcert=…) still verifies against it, verify-ca / verify-full are unchanged, and disable turns TLS off. A DSN with no sslmode behaves exactly as before.See connecting to your Postgres.2026-07-01
Column masking moves to a source-side query rewrite. Masking is now applied by rewriting the query before it runs, instead of redacting the result after execution. This fixes the blast radius of the earlier approach: a mask on one column no longer blanket-denies every aggregate or computed query, so
count(*), joins, and GROUP BY over a masked table run normally. It also closes the filter-inference bypass — a predicate or sort on a masked column now operates on the masked value. Shapes the engine can’t safely rewrite — views, whole-row serialization, opaque functions — still fail closed with a column_masking reject, now before the query runs. On Cloud this is on for every masked database; self-host opts in with MIDPLANE_MASK_SOURCE_REWRITE=1 (or per database, mask_source_rewrite: true).See column masking.2026-06-24
Expanded the column-masking transform catalog. On top of 0.12.0’s
full-redact and consistent-hash, five more transforms ship: null-out (type-preserving NULL), partial (reveal a prefix/suffix — this subsumes the retired keep-last-4), generalize (truncate a date or bucket a number), pseudonymize (deterministic realistic fakes — email, name, phone), and noise (proportional numeric jitter, the only non-deterministic transform). Each parametric rule declares the column type it accepts and fails closed — applying a transform to an out-of-domain column rejects the whole result set rather than silently passing it through.SELECT … INTO now denies under a read-only policy. It’s the legacy spelling of CREATE TABLE … AS SELECT, so it’s governed as a write to the new table — a read-only agent can no longer materialize a table this way. Previously the two spellings parsed differently and SELECT … INTO slipped through; they’re now indistinguishable to policy.See column masking and what Midplane blocks.2026-06-19
Column-level dynamic masking. Redact sensitive values in query results, declared per column on top of
table_access. The engine resolves each output field back to its true base column by provenance — so aliases, joins, and SELECT * are all covered — and applies the transform after execution, before any rows leave the engine. It’s fail-closed: a result shape it can’t prove safe to mask (a view, an aggregate, a whole-row output) is withheld entirely (a column_masking reject), never leaked. Initial transforms: full-redact, consistent-hash, and keep-last-4 (the catalog expands in 0.13.0). Masks need a per-deployment MIDPLANE_MASK_SALT; the engine refuses to boot a database that declares masks without it.See column masking.2026-06-18
Per-agent least-privilege database scope. The engine narrows each session to the databases a credential was granted, clamping writes to read-only where the grant says so. This is what lets each agent reach only the databases you allow, at the access you allow — chosen at OAuth consent or token creation.See roles & team access.
2026-06-12
The CLI grows into the OSS operator surface. OSS Midplane has no web UI by design — so the
midplane binary becomes the product UI. Every new command reuses the real enforcement path rather than reimplementing it. Output is human-readable on a TTY and JSON lines when piped (--json / --pretty to force either; color respects NO_COLOR).midplane init— an interactive setup wizard: introspect the database, pick per-table grants, and write a schema-validated, linted policy file (the DSN is never echoed or written).midplane query— send one statement through the server exactly as an agent would, with the same policy decision, audit row (agent_name=midplane-cli), and deny message. A verification harness, not a database client.midplane doctor— preflight + smoke checks in boot order, ending in an end-to-end MCP canary that proves the audit pipeline records the call.midplane audit deniesandmidplane audit show <query_id>— “what got blocked and why,” and the full event chain for one query.midplane policy test --server— dry-run against a running server’s currently-loaded policy via/admin/dry-run, not just a file.
2026-06-11
Dangerous-statement guardrails — on by default. A new policy rule,
dangerous_statement, denies categorically-destructive operations regardless of table_access — so a table you’ve granted read_write can’t be turned into a whole-table wipe or a schema change. block_unqualified_dml denies a DELETE/UPDATE with no WHERE clause (caught at any AST depth, including inside a CTE); block_ddl denies DROP/TRUNCATE/ALTER (CREATE is not blocked). Both default on, even with no policy file; opt out per flag in the guardrails block.Behavior change (safe-by-default): upgrading a deployment that marks tables read_write will start denying those shapes on those tables. The no-YAML default already denied all writes, so out-of-the-box outcomes are unchanged. See upgrading.2026-06-10
POST /admin/dry-run — test a policy decision without executing. “Would this SQL be allowed or denied?” answered by the same code that enforces policy at query time (parse → classify → table_access → guardrails → decide), stopped before execution — it never opens a database connection and never runs a statement. Ask with a literal sql or with (table, action) probes; the response carries a verdict, the matched_rule, and a policy_hash. This powers the Cloud dashboard’s policy test panel and midplane policy test --server.See HTTP endpoints.2026-06-05
Configurable HTTP bind host —
MIDPLANE_HOST. The Streamable HTTP transport was hard-bound to IPv4 (0.0.0.0); the bind host is now read from MIDPLANE_HOST. It defaults to 0.0.0.0, so self-host and local boot are unchanged — set it to :: to serve a dual-stack (IPv6 + IPv4) network, such as an IPv6-only private network. See environment variables.2026-06-04
midplane policy CLI — author, trust, and dry-run a policy file without hand-editing YAML blind. Four subcommands on the existing midplane binary:policy init— scaffold a commented policy file; with--url, list every table in thepublicschema (read-only) and emit each undertable_access. The DSN is never printed or written to the file.policy validate— parse the YAML against the same schema the server boots with. Offline-safe: an unset${VAR}doesn’t fail a structural check.policy lint— security-posture findings beyond schema validity (writes granted, policies that restrict nothing). Errors exit nonzero for CI gating.policy test --sql "..."— run a query through the real engine against the file’s policy and print the decision, the rule, and the exact agent-facing denial message — no database connection.
table_access). A non-recursive CTE’s name doesn’t bind inside its own body, so WITH audit_log AS (SELECT * FROM audit_log) SELECT * FROM audit_log reads the real audit_log in its body — which must be policy-checked. That body read was previously mistaken for a reference to the CTE itself and skipped, allowing a read of a denied table. Now a CTE’s own name is excluded while walking its body; recursive CTEs keep their self-reference, and legitimate shadowing (a body that reads nothing real) still allows.See the policy CLI and what Midplane blocks.2026-05-20
Per-token audit attribution. The Cloud proxy injects an
X-Midplane-Token-Id header on every forwarded request, naming the token that opened the session. The engine stamps mcp_token_id on every audit row from that session — so customers issuing multiple named tokens per connection (one for CI, one for an interactive analyst) can attribute every query to the token that authorized it. The column is additive and nullable; self-host and non-MCP callers read NULL.See tokens and the audit events reference.2026-05-02
Structured per-call intent.
intent is now a required structured field on the query tool’s input schema. Agents fill it the same way they fill sql, validated by the MCP SDK before the engine runs — so every audit row carries an LLM-supplied “why” (observed fill rate: 100% on stock Claude Code traffic). This replaced three earlier resolution channels (MCP _meta, SQL comment hint, HTTP header) that no off-the-shelf agent actually populated.See the audit trail.2026-05-01
Agent identity on every audit row. The MCP
initialize handshake already sends clientInfo: { name, version }; the audit row now stores them as separate agent_name and agent_version columns (rather than one combined string that would kill grouping and filtering). No agent action required — every MCP client already sends clientInfo.See the audit events reference.2026-04-30
Multiple databases. One
midplane/midplane container can now serve N databases through a single MCP endpoint. Configure them under a top-level databases: block, each with its own url and table_access. ${ENV_VAR} interpolation keeps DSNs out of YAML files.Dynamic tool surface. With two or more databases, query and describe_table gain a required database arg, list_tables gains an optional one (omitted = fan out across all databases), and a new list_databases tool reports each database’s policy summary. Single-database setups see no change. Every audit row is tagged with the originating database name.See multiple databases.2026-04-29
First tagged release. Three policy rules —
table_access, multi_statement, and parse_error — sit on the audit-before-execute pipeline, pinned by an adversarial SQL corpus covering CTE-hidden writes, stacked-statement injection, parser edges, and exec side-effects. 100% line coverage on the policy surface.Ships as @midplane/engine (the embeddable pipeline) and @midplane/mcp-server (three MCP tools over stdio and Streamable HTTP), plus a multi-arch, non-root production Docker image. Agent compatibility verified across Cursor, Claude Code, and Claude Desktop.- Per-table read/write policy via YAML, with schema-qualified keys and
default: read. COPYandLOCKdeny unconditionally — side effects the per-table YAML can’t grant.- Audit-before-execute ordering: if the pre-execute audit write fails, the query never runs.
midplane auditCLI (tail | stats | since) to read the audit log without writing SQL.- Anonymous, aggregate telemetry, disabled with
MIDPLANE_TELEMETRY=0.
Next steps
Upgrading
How to move a self-host instance between versions, including audit schema migrations.
Contributing
The dev setup and the highest-leverage contribution.