← All articles Engineering

The query you wrote is not the query we ran

OBTO Team · Insights from the Glass Box

Letting an agent send raw filters to a database shared by 419 tenants, and the three refusals that make it survivable.

This morning I asked our platform for a page record belonging to a tenant called acme-corp. Here is what I sent, and what the database actually received:

// what I asked for
{ "name": "blog", "domain": "acme-corp" }

// what executed
{ "name": "blog", "domain": "www" }

No error. No warning. The domain value I supplied was overwritten with the one I am actually entitled to, the query ran against my own tenant, and the response handed me back the filter that had really been used. That last part matters more than it looks, and most of this article is about why.

The uncomfortable question

Every agent platform eventually arrives at the same fork: does the model get to talk to the database directly? Saying no is defensible and costs you most of what makes an agent useful. Saying yes means a language model composes queries that run against production, and the model is not a trusted component. It is a very capable pattern-matcher that will occasionally produce a confident, well-formed, completely wrong filter.

We said yes. What made that decision survivable was accepting one thing early: the agent must never be the component that decides whose data it reads. Not because the model is malicious, but because a system whose safety depends on the model's good judgement has no safety property at all. It has a hope.

So the tenancy fields are not the agent's to set. When a query arrives, the server overwrites domain and app with the values the caller is authorised for, then executes. A cross-tenant read is not blocked, exactly. It is unsayable. There is no phrasing of the request that expresses it, which is a stronger property than a check that has to be right every time.

The part that surprised us: silent is wrong

Our first version filtered quietly, the way row-level security does. Ask for another tenant's rows, get an empty result. Clean, and by conventional security thinking, correct: leak nothing, not even the shape of what you refused.

It was the wrong default, and the reason is specific to who the caller is.

When a human hits an empty result set, they check their filter. When a language model hits an empty result set, it very often concludes that the data does not exist, and then reasons confidently from that conclusion. We watched an agent decide a tenant had no configured pages, and go on to helpfully offer to create them. It had simply been scoped, and had no way to tell the difference between "this is empty" and "this is not yours."

So the response now carries the filter that actually ran. The agent can see that its domain was rewritten, and an empty result becomes interpretable rather than misleading. This is the same instinct behind the structural checks on our write path: tell the caller precisely what happened, including when what happened was a refusal. Security that hides its own operation makes a human feel safe and makes a model hallucinate.

A rewrite is necessary and not sufficient

Rewriting the filter constrains one collection. MongoDB offers several ways to leave that collection while technically honouring the filter you were given, and this is where a naive implementation quietly fails.

$lookup joins into a second collection, and the sub-pipeline that does the joining carries its own match stage that nobody rewrote. $where and $function run arbitrary JavaScript inside the database engine. $out and $merge write the result set into a collection the caller never named, turning a read into a write. Each of these takes a correctly-scoped query and walks straight out the side door.

We refuse them by name, before execution, without trying to analyse intent:

{
  "error": "forbidden_operator",
  "message": "Forbidden operator '$where' at $.query.$where.
    This operator is blocked because it allows arbitrary JS
    execution or bypasses the per-app allowlist."
}

The denylist is deliberately blunt: $where, $function, $accumulator, $lookup, $out, $merge, $graphLookup. Some of those have entirely legitimate uses, and refusing them costs us real capability. We took that trade because a rule you can evaluate by looking at a key name is a rule that holds under adversarial input, and a rule that requires understanding a nested pipeline is a rule that eventually doesn't. Aggregations are simply not exposed yet; reads are find only. We would rather ship the narrow thing that is provably contained.

No ambient authority

Two smaller decisions carry more weight than they should.

First, collections are opt-in. An app declares what it exposes, each with its own row cap, and everything else is invisible. The default is not "the service account can read the database and we simply never write those queries" — a posture that survives exactly until an agent writes one.

Second, the override for platform operators is explicit per call, pinned to a single tenant, and logged. It is a parameter you must pass every time, never a mode you enter and forget. And it only lifts the collection allowlist. It does not lift tenancy. The opening example in this article was run with that override active, which is precisely why it is worth showing: even with the allowlist stood down, acme-corp still came back as www. The two controls are independent by design, because the failure we actually fear is the one where somebody has legitimately elevated for a good reason and forgotten they are still elevated.

What we would tell you to steal

Postgres row-level security enforces at the engine, which is better placement than an application-layer rewrite, and if you are on Postgres you should use it. We are not claiming to have invented tenant scoping. The pieces worth taking are the ones that come from the caller being a model:

None of this is about trusting the model more as it gets better. The model will get better. The boundary still belongs in the platform, because a guarantee that depends on the model's competence expires with every model release, and a structural refusal does not.

If you want to see the shape of it, the read tools are on every plan including the free one — pricing is here and you can start in about ten minutes. Point an agent at a collection, ask it for someone else's data, and watch the filter come back rewritten. For a gentler on-ramp with a different database, we wrote up connecting Postgres to an agent without handing it raw SQL.

Frequently asked questions

Is it safe to give an AI agent access to a production database?

It can be, provided the scoping is enforced on the server rather than requested in the prompt. The agent should never be the component that decides which tenant's data it reads. Rewrite the tenancy fields server-side before the query executes, refuse the operators that let a query escape that rewrite, and return the query you actually ran so the result can be audited.

What is tenancy injection?

Tenancy injection means the server overwrites the tenant-identifying fields of an incoming query with the values the caller is actually authorised for, before the query reaches the database. If a caller asks for domain acme-corp but is authorised for www, the executed filter contains www. The caller cannot express a cross-tenant read, because the field is not theirs to set.

Why block MongoDB operators like $lookup and $where?

A filter rewrite only constrains the collection being queried. $lookup joins into a second collection whose match stage was never rewritten, $where and $function execute arbitrary JavaScript inside the database, and $out and $merge write results into a collection the caller never named. Each one escapes the rewrite, so they are refused outright rather than inspected.

How is this different from Postgres row-level security?

Row-level security enforces at the database engine, which is stronger placement than an application-layer rewrite. The difference is what the caller learns. RLS filters silently, so a language model reading zero rows will often conclude the data does not exist. Returning the executed filter lets the model distinguish an empty result from a scoped one, which matters when the caller reasons about its own output.

Should an AI agent have write access to a production database?

Reads and writes deserve different treatment. Reads can be made safe with scoping and an allowlist. Writes need structural verification as well, such as refusing a patch whose anchor text no longer matches what is live, or refusing an upload whose bytes do not hash to what was promised. The principle is the same: a refusal the agent cannot argue with.

More from the OBTO blog