The three levels
The default for a table you don’t mention is
read — a fresh Midplane lets an agent explore and query, but it can’t change anything until you grant read_write on a specific table. A bounded DELETE … WHERE id = 1 is still denied on a read table; you opt into writes one table at a time, never by inference. Set the default to deny to lock down reads as well, so only the tables you list are visible.
- Cloud (dashboard)
- Self-host (YAML)
In the project’s database settings, set Default access to
read, then add per-table overrides: audit_log to deny, feature_flags to read_write. See writing policies.How a table name resolves
A query references tables by bare name (users) or schema-qualified name (stripe.charges). The engine resolves each reference to a policy level by taking the first match:
- Schema-qualified key —
FROM stripe.chargestries thestripe.chargeskey first. public.<table>— a bareFROM userstriespublic.usersbefore the bare key, so a policy written in canonical schema-qualified form matches agent SQL that uses bare names.- Bare
<table>— the bare key matches bare references, and any schema-qualified reference whose qualified key isn’t in the policy. default— if nothing else matches, the table takes thedefaultlevel.
search_path. To make the public fallback a guarantee rather than a guess, the executor pins search_path to public, pg_catalog per transaction, and SET is denied so agent SQL can’t rewrite it on a pooled connection. Tables outside public must be schema-qualified in both policy and SQL.
Discovery carve-outs
information_schema is always readable, even under default: deny. The canned list_tables and describe_table tools query it so an agent can discover the schema — blocking it would be self-defeating.
Postgres pg_catalog is not exempt. If your agent needs to read a pg_catalog view, grant it explicitly with a per-table entry — Midplane doesn’t open the system catalog by default.
Writes can’t hide
A write isn’t always at the top of a query — it can sit inside a CTE, a subquery, or aUNION arm. Midplane walks the entire AST and checks every write node against its target’s permission, no matter how deep:
SELECT, but the DELETE buried in the CTE is caught at the inner node. A CTE name that shadows a real table (WITH audit_log AS (SELECT 1) SELECT * FROM audit_log) is correctly treated as the CTE, not the table — but a schema-qualified reference (public.audit_log) always resolves to the real table.
Side-effecting statements are denied unconditionally —
SET, COPY, LOCK, NOTIFY, CALL — because their effects reach beyond a single table’s rows, so the per-table YAML can’t grant them (marking webhooks: read_write does not enable COPY webhooks TO '/tmp/leak'). CREATE TABLE … AS SELECT and its legacy spelling SELECT … INTO (0.13.0+) are governed as writes to the new table, which a read-only agent can’t materialize.Beyond table access
Table access decides which tables a query may touch. The rules below fire independently — some are configurable guardrails, some are always on — and catch dangerous shapes even on tables the agent is allowed to use. Every one is pinned by a real test in the adversarial corpus.Whole-table writes and schema changes
Whole-table writes and schema changes
Granting a table Two independently-toggled guards back this:
read_write lets the agent write rows — it must not also let the agent wipe the table or change its schema. The dangerous_statement guardrails — on by default (0.9.0+) — deny these categorically, regardless of table_access:block_unqualified_dml denies a DELETE/UPDATE with no WHERE clause — any WHERE, even WHERE true, makes it qualified, so this is the missing-WHERE footgun specifically — firing at every DELETE/UPDATE node, including one hidden in a data-modifying CTE; and block_ddl denies the DROP/TRUNCATE/ALTER family. CREATE is not blocked. Both default on; opt out per flag in the guardrails block.Caught by: dangerous_statement.Stacked-statement injection
Stacked-statement injection
A query that smuggles a second statement past the first is denied before it reaches the database.Midplane counts the parsed top-level statements, not the semicolons — semicolons inside string literals and comments don’t inflate the count, and a trailing semicolon on a single statement is fine. This is the Datadog Security Labs vector against the deprecated Anthropic Postgres MCP.Caught by:
multi_statement.Unparseable SQL
Unparseable SQL
If Midplane can’t parse a query, it can’t reason about it — so it denies rather than guessing.Empty input, comment-only input, and anything over the size cap are denied the same way. Midplane never enforces policy on text it can’t read.Caught by:
parse_error.Sensitive values read back in the clear
Sensitive values read back in the clear
The rules above decide whether a query runs. Column masking is different: it changes what the agent reads back from an already-allowed query (Masking is fail-closed: a query shape the engine can’t prove safe to rewrite is denied whole with a
0.12.0+), per column, layered on table access.column_masking reject rather than risk leaking an unmasked value. See column masking for the full mechanic — provenance resolution, filters seeing the masked value, and the shapes that fail closed.Caught by: column_masking, layered on table_access.Pinned by an adversarial corpus
These aren’t aspirational claims. Every shape Midplane catches is a real test in the adversarial corpus: 105 bypass attempts denied plus 52 legitimate queries allowed, with 100% line coverage on the policy surface. A bypass that gets through is a release blocker, and a legitimate query that’s wrongly denied is a tracked bug. The corpus is the contract — the docs read from the tests, not the other way around.Midplane is conservative by default: when a shape can’t be statically verified as safe, it’s denied. See the threat model for what’s in and out of scope.
Next steps
Write a policy
Grant the reads and writes your agent needs, table by table.
The policy engine
How these rules fit into the parse → policy → audit pipeline.
Policy schema reference
Every field and default in the
table_access and guardrails blocks.Threat model
What Midplane defends against, and what it explicitly doesn’t.