DEV Community

Jason Lee
Jason Lee

Posted on

valv Won't Let Your AI Agent Write a Single Line of SQL. That's the Whole Security Model.

valv

Every "let your AI agent talk to your database" product on the market right now solves the problem the same way: the model writes SQL, and some layer checks the SQL before it runs. valv, which took its open-source library into a full hosted platform on Product Hunt and Peerlist in July 2026, does the opposite. The model never sees SQL, never writes SQL, and never gets the chance to. It emits a JSON object. valv is the thing that turns that JSON into SQL, after checking it against a policy the model can't read or influence.

That's a narrower claim than "safe AI database access," and it's worth taking seriously precisely because it's narrow. Here's what valv actually ships, how the architecture holds up against the failure modes that have already put text-to-SQL agents in the CVE database, and where the limits are that the landing page doesn't advertise.

What happened

Joshua Knauber and a small team shipped the @valv/core library in May 2026 as an open-source authorization layer: point it at a database, write policies in TypeScript, and hand an LLM agent a set of tools instead of a connection string. In July, they turned it into a hosted platform — connect your databases once through a UI, manage roles and row-level policies without touching code, and let every team member's coding agent (Claude Code, Cursor, or anything that speaks MCP) query live data through those permissions instead of a shared superuser credential.

The GitHub org has, as of this writing, been renamed from valv-dev to vistal-dev (old links still redirect, and the README and npm packages still ship under the valv name). Nothing on the site or launch posts explains why, so treat it as a detail to watch rather than a settled fact — it might be a cosmetic rename, it might be the first sign of a repositioning. Either way it doesn't change what's actually running in the packages people are installing today.

What it actually does

valv sits between an AI agent and a live database — Postgres, MySQL, ClickHouse, and PostHog are the four supported sources — and gives the agent four read tools (list_resources, search_resources, describe_resource, query) plus, opt-in, three write tools (create, update, delete). The agent never gets a database credential and never gets to write a query string in the target dialect. It calls query with a structured object describing what it wants, and valv does the rest: validates the object against the live schema, applies the caller's policy, compiles the result to real SQL, executes it, and hands back rows.

The platform layer adds three things the library doesn't: a UI for connecting sources and defining roles without writing TypeScript, a chat interface wired directly to your data so the first answer doesn't require setting up an agent at all, and a shared memory store where agents write down what they learn about your schema — that orders.status = 5 means refunded, not cancelled, or that MRR is summed from subscriptions.amount and excludes one-time credits — so the next person's agent inherits that context instead of re-deriving it from scratch.

How it works

The mechanism is the interesting part, and it's documented in unusual detail for a two-month-old project. Three pieces do the actual work.

The query is a grammar, not a prompt. A model calling query sends something like this:

{
  "from": "orders",
  "select": [
    { "col": "status" },
    { "fn": "count", "args": [], "as": "orders" },
    { "fn": "sum", "args": [{ "col": "total" }], "as": "revenue" }
  ],
  "where": { "kind": "cmp", "op": ">=", "left": { "kind": "col", "name": "created_at" },
             "right": { "kind": "value", "value": "2026-06-01" } },
  "groupBy": ["status"],
  "orderBy": [{ "col": "revenue", "dir": "desc" }],
  "limit": 10
}
Enter fullscreen mode Exit fullscreen mode

That covers real analytics — filters with arbitrary and/or/not trees, aggregates, time-series bucketing, top-N ordering, conditional aggregation (countIf, sumIf) — but it is a closed grammar with an allow-listed function set, not a text field. There's no way to smuggle a second statement, a DROP, or a COPY ... FROM PROGRAM through it, because there's no string parser standing between the model's intent and the database. The model can only construct combinations of things valv's schema already knows how to compile.

Policy is enforced by construction, not by review. Each resource gets a policy function, written in code, that resolves against the caller's request context:

valv.policy("orders", (ctx) => ({
  read:   { tenant_id: ctx.tenant.id },
  fields: { deny: ["internal_notes"] },
}))
Enter fullscreen mode Exit fullscreen mode

The row filter isn't a suggestion the model can override — it's injected into the compiled WHERE clause after the model's query is parsed and before any SQL is emitted. Denied and unknown columns fail with the same error message, specifically so a model can't distinguish "this column doesn't exist" from "you're not allowed to see it" and use that signal to probe the schema. Default posture is deny-all: a resource is invisible until a policy exists for it.

Joins are constrained the same way. The model can only follow relations declared in the schema (belongsTo/hasMany), and every table a join touches gets its own policy and field allowlist applied independently — the classic "join around the RLS filter" attack doesn't have anywhere to land, because there's no single unscoped join the model can construct. Join depth, table count, and fan-out are capped, and every query runs under a statement timeout.

Writes get stricter handling than reads. create force-injects owned fields like tenant_id — the model can't set, omit, or override them. update and delete AND the policy predicate into the WHERE clause, and that predicate is mandatory: there's no implicit "all rows" write. Writable columns are a separate allowlist from readable ones, so a field can be visible without being settable.

Deployment is flexible, enforcement isn't. You can embed @valv/core plus a database adapter (@valv/clickhouse or @valv/prisma for Postgres/MySQL/SQLite) directly in your app and hand the resulting tools to the Vercel AI SDK, Anthropic, OpenAI, or Gemini. You can run @valv/mcp as a standalone Model Context Protocol server pointed at a database, and any MCP client — Claude Code, Cursor, OpenAI Codex — gets read-only access by default, narrowed with VALV_TABLES/VALV_EXCLUDE env vars or a full policy file. Or you use the hosted platform, which wraps both in a UI and adds the shared-memory and dashboard layer. The enforcement engine itself — @valv/core — is MIT-licensed and identical across all three paths; the platform is convenience and multi-user coordination on top, not a different security model.

One detail that matters for anyone using the "stored query, replayed later" dashboard pattern: a saved query is re-validated against the current caller's permissions every time it runs, not the permissions of whoever created it. Revoke someone's access and their dashboards stop returning data on the next load, without anyone touching the dashboard itself.

What changed versus the alternatives

The default way teams currently give an agent database access is one of three things, and each has a specific, documented failure mode valv is built to avoid.

Handing over a connection string. All-or-nothing access, credential sprawl, and no way to scope by user — valv's own FAQ leads with this comparison for a reason. Anyone who's set up a "read-only" Postgres role for an internal tool knows this decays fast: read-only still means every row of every table that role can see, including whatever ended up in an internal_notes column three years ago.

Native row-level security. Postgres and a handful of others support RLS natively, and Supabase has built a lot of its AI-facing story on it. It's the right primitive, but it's per-engine — ClickHouse and PostHog don't have anything equivalent — and it's not agent-aware: RLS scopes by database role, not by "which LLM call, on behalf of which user, with which tool permissions." valv's pitch is a single policy layer across heterogeneous engines that's expressed in terms your app already understands (request context, not database sessions).

Text-to-SQL agents. This is the one worth spending time on, because it's the actual competitive set and the risk isn't hypothetical. In 2026, CVE-2026-33324 documented a critical (CVSS 8.8) prompt-injection vulnerability in SQLBot, an LLM-based text-to-SQL chat system: user input was concatenated into the LLM prompt unfiltered, the SQL extracted from the model's response was executed without validation, and against a Postgres source that chain led to remote code execution via COPY FROM PROGRAM. A near-identical bug was reported independently in Langroid's SQLChatAgent — same root cause, same exploitation path, same RCE outcome when the database role had COPY/FILE/xp_cmdshell privileges. A separate LangChain issue walks through why "just add a SQL validator after generation" is harder than it sounds: catching multi-statement injection, data-modifying CTEs, and SELECT ... INTO writes all require essentially reimplementing a SQL parser as a denylist, and denylists miss things.

That's the structural argument for valv's approach: none of those bugs are possible in a system where the model literally cannot produce a string that reaches a SQL executor. It's the same logic parameterized queries applied to SQL injection twenty years ago, moved up a layer to cover the LLM instead of the HTTP request. It doesn't make valv immune to bugs — the query compiler itself is now the trust boundary, and bugs in that are just as dangerous as a missed sanitization rule would be elsewhere — but it does eliminate an entire class of vulnerability rather than trying to detect instances of it after the fact.

It's also worth placing against Convex, which is solving an adjacent but different problem: Convex's recent funding pitch is essentially "don't let an AI agent write your backend code at all." valv's bet is narrower and, arguably, more tractable — let the agent query live data, just never in a format expressive enough to be dangerous.

And it's worth placing against the generic "database MCP server" pattern that's proliferated since MCP shipped — small open-source servers that wrap psql or a driver and expose a run_query tool, usually with the safety story being "point it at a read-only user." That's not nothing, but it's the same shape of protection a connection string gives you: coarse, per-connection, and blind to who's actually asking. A read-only role still lets the model SELECT * FROM users across every tenant in the table. valv's row and column scoping is the piece those servers don't have, because adding it properly means building the compiler valv built, not adding a flag.

Approach Scoped by user/role Blocks injection structurally Cross-engine Self-hostable
Connection string No No N/A N/A
Native RLS (Postgres) Yes, by DB role Yes, within scope No Yes
Generic DB MCP server Rarely No Depends Usually
Text-to-SQL agent + validator Sometimes No (denylist-based) Sometimes Varies
valv Yes, by request context Yes, by grammar Yes (4 engines) Yes (core)

Why developers should care

Security is the headline, for the CVE-shaped reasons above. But there are three more mundane wins.

No bespoke API layer. The usual way to give an agent safe access to production data today is to hand-write a REST or GraphQL endpoint scoped to exactly what the agent needs, which means every new data question is a new endpoint. valv's policy is written once per resource and composes automatically with joins, so "what's MRR by plan, filtered to this tenant" doesn't require a new route.

No second copy of the data. valv queries the live database directly — no ETL pipeline, no warehouse, no cache layer to keep in sync or secure separately. That's a real cost and operational win versus a text-to-SQL tool that runs against a replicated analytics warehouse, but it also means agent query load lands directly on your production database. There's a statement timeout and join-depth cap, but nothing documented about query result caching or rate limiting an agent that iterates on the same expensive aggregate ten times in a debugging session. Worth load-testing before you point it at anything busy.

Lower lock-in than it looks. The enforcement engine is MIT-licensed and self-hostable; you can run the whole security model without ever touching the hosted platform. That's a meaningfully different risk profile than a proprietary SaaS text-to-SQL tool where the query engine, the guardrails, and the hosting are one inseparable black box.

Practical use cases

  • A support tool where the agent answers "what's this customer's order history" scoped automatically to the ticket's account, with no way for a crafted question to pull another tenant's rows.
  • An internal "ask the data" assistant replacing the Slack thread where someone with prod access runs a one-off query for a colleague — now anyone's Claude Code or Cursor session can answer it, scoped to what that person is allowed to see.
  • Coding agents doing live debugging against staging or a read-scoped production mirror without a human relaying queries back and forth.
  • Dashboards an agent builds once from a natural-language ask, that keep re-checking the viewer's permissions on every load instead of baking in whoever built them.
  • A multi-tenant SaaS company giving each customer's account team an agent that can answer "how is this specific customer using the product" without that agent — or a prompt-injected version of it — ever being able to widen its own scope to a different customer, because the tenant filter isn't part of the prompt at all; it's compiled in from the request context before the model's query is even validated.
  • Onboarding a new data analyst or support hire onto a schema that took the previous person months to learn intuitively — the shared memory layer means the enum mappings, metric definitions, and "Q1 means Feb–Apr here" caveats a departing employee would normally take with them get written down by the agents that already worked with the data, not lost when the person leaves.

What the launch page doesn't tell you

A few things worth knowing before treating this as production-ready.

It's very new. The GitHub repository was created in May 2026, last pushed in July, has two contributors, and — at the time of this research — three stars. That's not a knock on the engineering, which is unusually well-documented for a project this size, but it is a real signal: this is a young dependency to put in front of production data, with none of the track record that comes from surviving a year of adversarial use.

The query grammar is a deliberate subset of SQL, and the ceiling isn't advertised. Filters, aggregates, time-series, top-N, and conditional aggregation are documented and clearly cover most analytics questions. There's no mention of recursive queries, window functions beyond what the exposed function allowlist covers, or arbitrary subqueries — which is exactly the trade-off you'd expect from a closed grammar, but it means the honest way to evaluate this is against your own hardest real query, not the MRR-by-month dashboard on the landing page.

Four data sources, no writes by default at launch. Postgres, MySQL, ClickHouse, and PostHog only — no MongoDB, no Snowflake or BigQuery, no vector stores. And while the open-source library supports opt-in write tools, at least one independent review of the hosted platform noted writes were blocked entirely at launch, which is a stricter default than the library ships with. If your use case needs an agent to actually mutate data rather than just read it, check the platform's current posture directly rather than assuming library and platform match.

Pricing isn't public yet. The platform is free to start with no card required, and the Product Hunt and Peerlist launches both carried a 50%-off-first-three-months promo code — but standard pricing past that window isn't published anywhere I could find. Budget for "unknown" until you talk to them.

Independent read

The core design decision — never let the model produce a string that reaches a SQL executor — is the right instinct, and it's validated by exactly the kind of vulnerability report (CVE-2026-33324, the Langroid RCE, the LangChain issue) that keeps showing up wherever "LLM writes SQL, then something checks it" is the architecture. Structured-query-then-compile is a stronger boundary than generate-then-validate, for the same reason parameterized queries beat string-escaping: you're closing a class of bug, not chasing instances of it.

The open question isn't whether the approach is sound, it's whether the grammar stays expressive enough as real usage pushes on it. Every closed query language eventually meets an analyst who needs the one aggregation it doesn't support, and the answer at that point is either "extend the grammar" (which is exactly the kind of surface-area growth that reintroduces bugs) or "fall back to something less safe for that one case" (which quietly reopens the hole the whole design exists to close). Nothing in the current docs suggests they've hit that wall yet, but a three-month-old, three-star project hasn't had much time to.

Who should try it, who should wait

Try it if you're already running MCP-based coding agents against a Postgres, MySQL, or ClickHouse database and currently solving "safe agent access" with a read-only credential and a prayer. The library is free, self-hostable, and the policy model is a genuine upgrade over role-level database grants, especially for multi-tenant SaaS where row-level tenant scoping is the whole ballgame.

Wait if you need agents to write to production data as a core workflow (check the platform's current write posture first), if your data lives in a warehouse or database outside the four supported engines, or if you need the kind of compliance documentation and vendor maturity that a two-contributor, two-month-old repo isn't going to have yet regardless of how good the architecture is.

Ignore it if you've already got solid native RLS in place, a small trusted set of people running ad hoc queries, and no near-term plan to put an agent in that loop — you don't have the problem this solves.


If you've built or evaluated an agent-facing data layer in front of a live production database — did you go with a constrained query grammar like this, native RLS, a read replica plus prompt-level guardrails, or something else entirely? What broke first, and was it the thing you expected to break?

Sources:

Top comments (1)

Collapse
 
mads_hansen_27b33ebfee4c9 profile image
Mads Hansen

The constrained grammar is the strongest part of this design, but it also means the compiler and policy-composition logic become the security kernel. I would want that boundary tested like a database engine, not like ordinary application code.

A useful suite would generate structured queries across every adapter, compile them, and compare execution against a small reference interpreter over the same fixture data. Then mutate tenant context, join paths, aliases, NULLs, nested boolean trees, and denied fields and assert two invariants: the result is never broader than policy allows, and policy behavior is equivalent across Postgres/MySQL/ClickHouse. Those are the places where a grammar can be safe while a compiler bug is not.

I would also keep a least-privilege database role (and native RLS where available) underneath the policy layer. That is not redundant: it bounds the blast radius of a compiler or context-binding defect. Finally, row/column policy alone does not stop aggregate differencing, so sensitive analytics may need minimum cohort sizes, overlap/history budgets, and query-cost limits based on the plan rather than only a statement timeout.