DEV Community

Dipankar Sarkar
Dipankar Sarkar

Posted on

We let an AI agent hit a database 1034 times. Text-to-SQL ran 23 unsafe ops. The policy layer ran zero

The moment you give an AI agent database access, you inherit every question a junior
engineer with production credentials raises, except the agent does not get tired and
does not ask permission.

What if it reads a column full of PII. What if it writes a query that scans the whole
table and bills you for it. What if a user tricks it, through prompt injection, into
deleting rows. How do you prove, after the fact, what it actually did.

The common answer is text-to-SQL: let the model write SQL, run it. That is exactly
the wrong layer to enforce safety, because by the time you have a SQL string you have
already lost. We benchmarked it against the Spider dataset, 1034 natural language
queries. Text-to-SQL executed 23 unsafe operations and left no audit trail. SQL
injection was possible.

ormai is a different bet. Enforce at the ORM layer, not the prompt layer. On the
same 1034 queries it executed 0 unsafe operations, SQL injection was not possible,
and every call was logged.

The core idea: agents get typed tools, not a SQL prompt

OrmAI wraps your existing ORM models in a policy-enforced runtime. The agent never
sees or writes raw SQL. Instead it gets a small set of typed tools:

  • Read-safe: db.query, db.get, db.aggregate, db.describe_schema
  • Write-safe: db.create, db.update, db.delete, db.bulk_update, each gated by policy

Every request the agent makes is compiled into a parameterized ORM query. The
database never receives an agent-authored SQL string. That single architectural choice
is what closes the SQL injection hole: there is no string to inject into.

How it works: a runtime between the agent and the ORM

OrmAI sits as a layer between the agent and your ORM. A request flows through three
enforcement stages before it ever reaches the database:

Your Agent
   | calls a typed tool
OrmAI Runtime
   [ Policy Enforcer ] [ Audit Logger ] [ Tenant Scope Filter ]
   | parameterized queries only
Your ORM (SQLAlchemy / Prisma / Drizzle / ...)
Enter fullscreen mode Exit fullscreen mode

The policy enforcer decides what is allowed: which models, which fields, which
operations, and how much. Field-level policies hide passwords and mask emails
automatically. Query budgets cap rows, include depth, and statement timeouts so a
runaway query cannot run away. Writes are off unless you explicitly enable them, and
you can require a reason or human approval for sensitive models.

The tenant scope filter auto-injects a tenant predicate into every query.
.tenantScope('tenant_id') means a request in tenant A's context physically cannot
return tenant B's rows, whether or not the agent remembered to filter. Isolation is
built in, not bolted on and not left to the prompt.

The audit logger records every call with the principal, tenant, trace ID, input,
and output. When someone asks "what did the agent do", you have the answer.

You describe all of this with a PolicyBuilder, which reads like the security review
you would want to write anyway:

from ormai.utils import PolicyBuilder, DEFAULT_PROD

policy = (
    PolicyBuilder(DEFAULT_PROD)
    .register_models([Customer, Order])
    .deny_fields("*password*", "*secret*", "*token*")
    .mask_fields(["email", "phone"])
    .tenant_scope("tenant_id")
    .enable_writes(models=["Order"], require_reason=True)
    .build()
)
Enter fullscreen mode Exit fullscreen mode

Presets ship for the common postures: DEFAULT_DEV is permissive, DEFAULT_INTERNAL
is moderate, DEFAULT_PROD is strict. You start from one and tighten.

A concrete wiring

The Python path is deliberately short. Point it at your existing SQLAlchemy engine and
session, hand it a policy, and you get a toolset the agent can use:

from ormai.quickstart import mount_sqlalchemy
from ormai.utils import DEFAULT_DEV

# your existing SQLAlchemy models + session
toolset = mount_sqlalchemy(
    engine=engine,
    session_factory=Session,
    policy=DEFAULT_DEV,
)

# done. the agent now has db.query, db.get, db.aggregate, db.describe_schema
Enter fullscreen mode Exit fullscreen mode

It is not Python-only. There is a TypeScript/Node.js implementation too, with the same
policy model expressed through the same builder pattern, and it plugs into Vercel AI
SDK, LangChain.js, and MCP among others. On the ORM side the coverage is wide: on
Python it supports SQLAlchemy, SQLModel, Django ORM, Tortoise, and Peewee; on
TypeScript it supports Prisma, Drizzle, and TypeORM. Schema introspection is automatic,
so it discovers your models, fields, relations, and primary keys rather than making
you redeclare them.

Where it does not fit

The mandatory honesty section.

  • It constrains what your agent can do, on purpose. OrmAI is about safe, typed,
    policy-bounded access. If your use case genuinely needs arbitrary analytical SQL,
    window functions, recursive CTEs, hand-tuned joins, the typed tool surface will feel
    like a cage. That is the trade you are buying: expressiveness for safety.

  • The policy is only as good as you write it. OrmAI enforces your rules. It does
    not guess which fields are sensitive. Ship a policy that forgets to deny a secret
    column and the runtime will faithfully expose it. The presets help, but the review
    is still yours.

  • It is another layer in the request path. Requests compile through the runtime
    before hitting the ORM. For most agent workloads that overhead is irrelevant next to
    the model call, but if you are chasing raw database latency on a hot path, measure
    it.

  • It assumes you use one of the supported ORMs. The value comes from wrapping an
    existing ORM. If your data access is hand-rolled SQL or an unsupported ORM, there is
    nothing for OrmAI to wrap yet.

  • It reduces risk, it does not eliminate it. Zero unsafe ops on the Spider
    benchmark is a strong signal, not a proof for every schema and every prompt. Treat
    it as defense in depth, keep your database-level permissions tight anyway.

Takeaways

  • Safety belongs at the ORM layer, not the prompt layer. Once you have a SQL string from a model, you have already lost the injection fight.
  • Typed tools plus parameterized queries is what took SQL injection off the table in the 1034-query benchmark, not a cleverer prompt.
  • Field masking, tenant scoping, and query budgets are the three controls that turn "the agent can read the database" into "the agent can read exactly what policy allows".
  • Audit everything. The question "what did the agent do" only has a good answer if you logged the principal, tenant, and trace on every call.

Code, the Spider benchmark demo, and the policy docs are here:
https://github.com/neul-labs/ormai

If you have wired an agent to a production database, I want to know how you are
scoping tenants today. Kick the tyres, issues welcome.

Top comments (0)