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.
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.
Terminalpgbeam 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 500Issue the credential and emit the config
The --client flag prints the entry in the shape Cursor wants, so there is no format to remember.
Terminalpgbeam agents create --name cursor --policy pol_xxx --client cursor --expires 30dAdd 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_..." } } } }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.
Terminalpgbeam 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.
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 101 Verdict: mask Rule: masked_column
Masked: customers.email (redact), customers.ssn (hash)
2 Verdict: block Rule: masked_column_predicate
3 Verdict: allow Rule: okLine 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