Database Access: You Wanted the Schema and You Granted the Rows
A SELECT against customer records is not a read. It is a disclosure with no step in it that looks like an export.
Schema is what you want; rows are what you grant
Connecting an agent to a database is usually motivated by schema questions. What columns exist, what the foreign keys are, which indexes are present, whether this migration is safe given what is actually deployed. Those are excellent reasons, and answering them needs the schema and nothing else.
What most people grant is a connection, which carries the schema and every row behind it. That difference is the whole subject of this article, because rows read by an agent become context, and context becomes part of a request to a model provider.
Which means a SELECT against a table of customer records is
not a read. It is a disclosure, performed by a process that thought it was
being helpful, with no step anywhere in it that looks like an export.
"Read-only" is not the safety property people think it is
A read-only credential prevents modification. It does nothing about disclosure, and disclosure is the risk that actually applies here. The mental model that says read-only equals safe comes from a world where the reader was a person sitting at a terminal, and it does not transfer to a reader that forwards everything it sees to a third party.
This is worth saying plainly because "we only gave it read access" is the sentence that ends most internal discussions of this, and it answers the wrong question.
Give it the schema without the data
The arrangement that satisfies the actual need. Dump the schema and let the agent read that, rather than connecting it to anything live.
#!/usr/bin/env bash
# Structure only. No rows, no sequence values, no ownership noise.
set -euo pipefail
pg_dump --schema-only --no-owner --no-privileges \
--exclude-table='*_audit' \
"$DATABASE_URL" > db/schema.sql
# Row counts are useful for reasoning about query cost and disclose nothing.
psql -At "$DATABASE_URL" -c "
select relname || ': ~' || reltuples::bigint
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'r'
order by reltuples desc" > db/row-counts.txt
Commit both. Now the schema is version controlled, it is available to every agent session with no credential at all, it diffs when it changes, and it is reviewable. The row counts cover most of what the agent would have wanted rows for — knowing whether a table has a thousand or forty million entries is what determines whether a query plan matters.
When you genuinely need rows
Sometimes the schema is not enough: you need to know the shape of the data, whether a nullable column is actually null in practice, what values a status field really takes, whether a supposedly unique field has duplicates.
Those are legitimate and they do not need production. A seeded development database, or an anonymised copy, answers all of them. If your team does not have one, this is a good reason to build one, because the same artefact makes testing better and makes every one of these questions safe to ask.
The common practice of restoring a production dump into a development environment means the "safe" database contains exactly the same personal data as the real one. Check before assuming that pointing an agent at development resolves anything.
Constrain at the connection, not in the prompt
If a live connection is genuinely required, the constraints belong in the database and the connection configuration, where an instruction cannot be talked past.
A dedicated role with access to specific schemas. Column-level grants that exclude anything personal. Views that expose structure with sensitive columns masked. A statement timeout, because an agent will eventually write an accidental full scan across your largest table. A row limit enforced by the connection layer rather than requested in a prompt. And a replica, never the primary — an exploratory query with a bad join should not be able to affect anything users touch.
The timeout is not optional
Worth its own paragraph because it is the most likely thing to actually go wrong, and it has nothing to do with security.
An agent exploring an unfamiliar schema writes queries without knowing the cardinalities. A join that looks reasonable against the schema can be catastrophic against the data, and on a shared replica that is an incident affecting other people's dashboards. A statement timeout of a few seconds turns that into an error message the agent can learn from, which is exactly what you want.
Migrations are the good case
Writing a migration is where schema access pays off most clearly. An agent that can read the current schema will write DDL that matches reality rather than matching what the model files claim, and those two drift in every long-lived system.
Three things to insist on. A down migration, always, even when it is a comment explaining why the change cannot be reversed — that comment is itself the useful output. Explicit handling of the data migration, separate from the structure change. And an assessment of locking behaviour: whether the statement takes a lock that blocks writes, and for how long against the actual row count. The general treatment is in schema migration generation; the relevance here is that all three are answerable from a schema dump plus row counts, with no data access at all.
Test migrations against a copy, not a description
The verification step that makes migration work trustworthy: restore a recent structural copy, run the migration, run the down migration, run it again. If all three succeed and the schema matches at each stage, the migration is real rather than plausible.
That loop is scriptable, takes a couple of minutes, and is the difference between a migration that has been reasoned about and one that has been tried. An agent can run it unattended, and it produces a definite answer.
Query results are the disclosure, not the connection
A distinction worth holding onto when thinking about controls. The connection is the capability; the result set is the event. So the controls that matter most are the ones that bound what comes back — row limits, column exclusions, masked views — rather than the ones that gate whether a connection exists.
It also means the audit question is about queries and their result sizes, not about sessions. A log of every statement an agent ran, with the row count returned, answers "what did it see" in a way that a connection log never does.
Connection strings are credentials in the worst place
They live in environment variables, configuration files, developer dotfiles, and occasionally in a comment at the top of a script. Each of those is a file an agent may read while investigating something unrelated, and a connection string is a complete credential.
Keep them out of the workspace, deny the paths where they live, and prefer short-lived database credentials where your infrastructure supports it. The general form of this argument — that an agent inherits authenticated access rather than needing to find secrets — is in ambient authenticated sessions, and database access is its sharpest instance.
What to do about the schema you cannot commit
Some schemas contain information that is itself sensitive: table and column names revealing an unreleased product, an acquisition, a customer. Committing the dump is the right default and it is not universal.
Where that applies, the answer is a filtered dump rather than a live connection — exclude the schemas in question, and let the agent work without them. An agent that does not know a table exists cannot write a query against it, which is a cleaner control than any rule about what it should avoid mentioning.
The arrangement I would recommend
Schema dumped to a file in the repository and refreshed by CI. Row counts alongside it. No live connection in ordinary sessions. A seeded local database with synthetic data for questions about data shape. Where a live connection is genuinely needed: a replica, a dedicated role, masked views, a statement timeout, a row limit, and a query log.
That covers every legitimate use I have encountered, and the one it does not cover — ad-hoc exploration of real customer data — is a thing a person should be doing deliberately in a tool built for it, not something an agent should stumble into while investigating a bug.
Exploratory queries deserve a different tool
A framing that resolves most of the tension here. The thing people occasionally want — "what does the data actually look like for customer X" — is a support or analytics task, and organisations that take it seriously already have a tool for it: an admin interface with access control, audit logging and a reason recorded for each lookup.
Routing that through an agent's database connection bypasses all of it, not maliciously but structurally, because the agent has no notion of a lookup requiring a justification. If your organisation has such a tool, the agent should not be a second path around it; if it does not, the agent connection is not the place to discover you needed one.
The schema file has a second benefit
Once the schema lives in the repository, it becomes context for every session at no marginal cost — and it improves work that has nothing to do with databases. Code that reads a table, a type definition that mirrors a row, a query written by hand: all of them are checked against the real schema rather than against the agent's inference from surrounding code.
That is a quiet, continuous improvement, and it is the strongest practical argument for the file-based approach over the connection-based one. A connection helps when the agent thinks to use it. A committed file helps always.
Keep the dump current or it becomes a liability
A stale schema file is worse than none, because it is confidently wrong: an agent will write code against columns that no longer exist and a migration against a state you left behind two months ago.
Regenerate it in CI on every merge that touches migrations, and fail the build if the committed file differs from what the migrations produce. That is a ten-line check and it converts the file from documentation, which rots, into a generated artefact, which cannot.
If you do only one thing from this article, dump the schema into the repository and stop connecting agents to live databases for questions that a file answers. It takes twenty minutes, it removes the entire disclosure question from ordinary work, and it makes every session better at the code that talks to those tables.
The question to ask before granting anything
A single test that resolves most cases: would you be comfortable pasting the result of this query into a document that leaves your organisation? Because functionally, that is what happens when it enters an agent's context.
For a schema, the answer is almost always yes. For row counts, yes. For a sample of synthetic data, yes. For twenty rows of a customers table, no — and the fact that nobody typed an export command does not change the answer. Framing it that way moves the conversation from what the credential permits to what the request discloses, which is the axis that matters.
The pattern generalises past databases, incidentally. Anywhere an agent can reach a system holding real data — an internal admin API, a support tool, an analytics warehouse, a log store with request bodies in it — the same reasoning applies: the capability is the connection, the event is the result, and the control that matters bounds what comes back rather than whether the connection exists.
What an agent needs from a database is almost always the schema, and what a connection grants is the schema plus every row. Rows read become context and context leaves your machine, so read-only does not make it safe — disclosure, not modification, is the risk. Dump the schema and row counts into the repository instead, use a seeded database for questions about data shape, and if a live connection is unavoidable put the constraints in the database: replica, dedicated role, masked views, statement timeout, row limit, and a log of every query with its result size.