Skip to main content
Table access is how you decide what an agent can touch. Every table a query references resolves to one of three levels, and the engine denies the whole query if any referenced table fails its required permission. On top of that, a small set of guardrails and always-on rules catch dangerous query shapes regardless of what you’ve granted. Every query is parsed into an AST and checked against these rules — never pattern-matched as text.

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.
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:
  1. Schema-qualified keyFROM stripe.charges tries the stripe.charges key first.
  2. public.<table> — a bare FROM users tries public.users before the bare key, so a policy written in canonical schema-qualified form matches agent SQL that uses bare names.
  3. Bare <table> — the bare key matches bare references, and any schema-qualified reference whose qualified key isn’t in the policy.
  4. default — if nothing else matches, the table takes the default level.
This mirrors how Postgres resolves bare names against its default 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 a UNION arm. Midplane walks the entire AST and checks every write node against its target’s permission, no matter how deep:
The first looks like a 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.
Granting a table 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:
Two independently-toggled guards back this: 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.
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.
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.
The rules above decide whether a query runs. Column masking is different: it changes what the agent reads back from an already-allowed query (0.12.0+), per column, layered on table access.
Masking is fail-closed: a query shape the engine can’t prove safe to rewrite is denied whole with a 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.