Recipe

Cursor, with PII masked in the results

Masking is a rewrite rather than a refusal: the query runs, the row comes back, and the named columns arrive as a redaction token or a hash. The agent keeps working and the raw value never leaves your database.

Client
Cursor
Policy shape
column masking

The agent needs rows from a table that has personal data in it, and the personal columns must not reach the model context.

Three kinds are available per column: redact replaces the value with a token, hash replaces it with a digest so rows can still be told apart, and null blanks it. The important part is not the rewrite, it is the guard around it: a masked column cannot be used in a filtering or ordering position, because a column you can still compare against is a column you can confirm a guess about.

Wire it up

The masking rules go on the policy, in table.column=kind form, and are repeatable. Nothing changes in your schema and no view layer is created.

  1. Create the policy with the masking rules

    Pick the kind per column rather than per table. redact is right for a value the agent never needs, hash for one it needs to distinguish rows by, and null where an empty value reads better to the model than a token.

    Terminal
    pgbeam policies create \
      --name cursor-masked \
      --mode read_only \
      --allow public.customers \
      --allow public.orders \
      --mask customers.email=redact \
      --mask customers.ssn=hash \
      --mask customers.phone=null \
      --max-rows 500
  2. Issue the credential and emit the config

    The --client flag prints the entry in the shape Cursor wants, so there is no format to remember.

    Terminal
    pgbeam agents create --name cursor --policy pol_xxx --client cursor --expires 30d
  3. Add it to the project

    Cursor reads a per-project file, so this can be committed alongside the repository it belongs to. The token should not be; keep it in the environment or issue a short-lived credential.

    .cursor/mcp.json
    {
      "mcpServers": {
        "pgbeam": {
          "url": "https://<project>.proxy.pgbeam.app/mcp",
          "headers": { "Authorization": "Bearer pba_..." }
        }
      }
    }
  4. Confirm the mask and the guard

    The first statement should come back as a mask verdict naming the columns. The second should be refused, and that refusal is the guard doing its job.

    Terminal
    pgbeam policies dry-eval --policy pol_xxx --sql "SELECT email, ssn FROM public.customers"
    pgbeam policies dry-eval --policy pol_xxx --sql "SELECT id FROM public.customers WHERE email = 'ada@example.com'"

The rewrite, and the query it refuses

Three statements. The first is masked, the second is refused for a reason worth understanding, and the third shows that masking is per column rather than per table.

Statements
1  SELECT id, email, ssn FROM public.customers WHERE id = 42
2  SELECT id FROM public.customers WHERE email = 'ada@example.com'
3  SELECT id, city FROM public.customers LIMIT 10
pgbeam policies dry-eval
1  Verdict: mask     Rule: masked_column
   Masked:  customers.email (redact), customers.ssn (hash)
2  Verdict: block    Rule: masked_column_predicate
3  Verdict: allow    Rule: ok

Line 1 runs. The agent gets the row it asked for, with the id intact, email as a redaction token and ssn as a digest. Nothing had to be rewritten in the agent's prompt for this to happen.

Line 2 is the case that makes masking real. If it were allowed, the agent could confirm a guessed email address one query at a time, and the address would never appear in a result set. A masked column that is still comparable is an oracle, so filtering on one is refused.

Line 3 is untouched, because city is not a masked column. Masking is per column, so allowlisting a table with personal data in it does not mean giving up the rest of the table.

What this does not cover

  • A masked column cannot appear in a WHERE, ORDER BY, GROUP BY, or JOIN condition. That is deliberate, and it means a query the agent expected to work will be refused with masked_column_predicate rather than quietly returning a masked value.
  • Masking applies to the columns you name. A column added by a later migration is not masked until somebody adds it, and a view that recomputes the value under a different name is a different column.
  • Results that carry masked columns need the row description in the same protocol cycle, so a client caching prepared statements will be asked to resubmit as a simple query rather than being silently unmasked.

Questions

Safe Postgres access for your agents

Start with a 14-day free trial. No credit card required.

Get Started