> ## Documentation Index
> Fetch the complete documentation index at: https://midplane.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Table access and guardrails

> How Midplane decides what an agent can read, write, and touch — the three access levels, name resolution, and the guardrails that catch dangerous shapes.

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

| Level        | Reads   | Writes  |
| ------------ | ------- | ------- |
| `deny`       | denied  | denied  |
| `read`       | allowed | denied  |
| `read_write` | allowed | allowed |

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.

<Tabs>
  <Tab title="Cloud (dashboard)">
    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](/docs/policies/overview).
  </Tab>

  <Tab title="Self-host (YAML)">
    ```yaml theme={null}
    table_access:
      default: read           # default for tables not listed below
      tables:
        users:            read
        audit_log:        deny
        feature_flags:    read_write
        "stripe.charges": read   # schema-qualified key, matched first
    ```

    Grant a write by marking a table `read_write`. See the [policy schema](/docs/reference/policy-schema).
  </Tab>
</Tabs>

## 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 key** — `FROM 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`](/docs/reference/mcp-tools) 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:

```sql theme={null}
WITH x AS (DELETE FROM users RETURNING *) SELECT * FROM x   -- DENY (inner DELETE on a read table)
INSERT INTO webhooks (msg) SELECT msg FROM audit_log        -- DENY (read side hits a deny table)
```

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.

<Note>
  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.
</Note>

## 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](/docs/security/adversarial-corpus).

<AccordionGroup>
  <Accordion title="Whole-table writes and schema changes" icon="bomb">
    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`*:

    ```sql theme={null}
    DELETE FROM orders                  -- DENY (no WHERE)
    UPDATE orders SET status = 'void'   -- DENY (no WHERE)
    DROP TABLE orders                   -- DENY (DDL)
    TRUNCATE orders                     -- DENY (DDL)
    ALTER TABLE orders ADD COLUMN x int -- DENY (DDL)
    ```

    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`](/docs/reference/policy-schema) block.

    **Caught by:** [`dangerous_statement`](/docs/reference/denial-reasons).
  </Accordion>

  <Accordion title="Stacked-statement injection" icon="syringe">
    A query that smuggles a second statement past the first is denied before it reaches the database.

    ```sql theme={null}
    SELECT 1; DROP TABLE users   -- DENY (multi_statement)
    ```

    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](https://securitylabs.datadoghq.com/) vector against the deprecated Anthropic Postgres MCP.

    **Caught by:** [`multi_statement`](/docs/reference/denial-reasons).
  </Accordion>

  <Accordion title="Unparseable SQL" icon="file-x">
    If Midplane can't parse a query, it can't reason about it — so it denies rather than guessing.

    ```sql theme={null}
    SELECT 'unterminated   -- DENY (parse_error)
    ```

    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`](/docs/reference/denial-reasons).
  </Accordion>

  <Accordion title="Sensitive values read back in the clear" icon="mask">
    The rules above decide whether a query *runs*. [Column masking](/docs/concepts/masking) is different: it changes what the agent *reads back* from an already-allowed query (`0.12.0+`), per column, layered on table access.

    ```sql theme={null}
    SELECT email FROM users   -- ALLOWED, but email comes back redacted
    ```

    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](/docs/concepts/masking) for the full mechanic — provenance resolution, filters seeing the masked value, and the shapes that fail closed.

    **Caught by:** [`column_masking`](/docs/concepts/masking), layered on `table_access`.
  </Accordion>
</AccordionGroup>

## Pinned by an adversarial corpus

These aren't aspirational claims. Every shape Midplane catches is a real test in the [adversarial corpus](/docs/security/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.

<Note>
  Midplane is conservative by default: when a shape can't be statically verified as safe, it's denied. See the [threat model](/docs/security/threat-model) for what's in and out of scope.
</Note>

## Next steps

<Columns cols={2}>
  <Card title="Write a policy" icon="table" href="/docs/policies/overview" horizontal>
    Grant the reads and writes your agent needs, table by table.
  </Card>

  <Card title="The policy engine" icon="cog" href="/docs/how-it-works" horizontal>
    How these rules fit into the parse → policy → audit pipeline.
  </Card>

  <Card title="Policy schema reference" icon="file-code" href="/docs/reference/policy-schema" horizontal>
    Every field and default in the `table_access` and `guardrails` blocks.
  </Card>

  <Card title="Threat model" icon="shield-alert" href="/docs/security/threat-model" horizontal>
    What Midplane defends against, and what it explicitly doesn't.
  </Card>
</Columns>
