Gate SQL writes so agents never hold DATABASE_URL
A developer using Cursor asked Claude to "clean up the test data" in production. Claude ran DELETE FROM users WHERE email LIKE '%+test%'. The predicate matched 1,847 production accounts. Production DATABASE_URL was in the MCP config because it was the only connection string on hand.
The model can recite that DELETE is dangerous. The root cause was architectural: the agent held DATABASE_URL. Nothing in the path was an evaluate.
Fix credential ownership
Default DB MCP: the host holds a connection string, and the model chooses SQL. Prompt text that says "this is production" is advisory. The driver still runs the statement.
A read-only URL works until someone needs an INSERT, a status update, or a migration. Then the choice becomes: hand over write credentials and hope, or remove the tool. Prefer a third path: the gate owns DATABASE_URL, classifies, evaluates sql.read / sql.write / sql.ddl, and executes only on allow.
Configure @limetry/sql
@limetry/sql (open source) owns DATABASE_URL. The agent never receives the raw connection string. It gets limetry_sql_query. On each statement the gate:
- Classifies with a conservative keyword heuristic —
read,write,ddl, orunknown. Unknown maps tosql.write(over-gate rather than unguarded write). - Evaluates an
ActionIntentwithaction_type: "sql.read","sql.write", or"sql.ddl". - Decides: allow (execute and return results), deny (structured error), or
approval_required(park until operator signs off).
{
"mcpServers": {
"limetry-sql": {
"command": "npx",
"args": ["-y", "@limetry/sql"],
"env": {
"DATABASE_URL": "postgresql://...",
"LIMETRY_API_KEY": "...",
"LIMETRY_POLICY_ID": "...",
"LIMETRY_BASE_URL": "http://localhost:3810"
}
}
}
}
The agent sees results from allowed reads and clear errors from denied writes. It never sees DATABASE_URL. Point LIMETRY_BASE_URL at your self-run Limetry server.
Keep classification conservative
This is a heuristic, not a full SQL parser. Dialects, extensions, and CTEs that wrap writes will outrun one. Map INSERT / UPDATE / DELETE / DROP / ALTER / TRUNCATE / CREATE / GRANT to write or DDL; unknown → sql.write; else read. Over-gating costs seconds. Under-gating deletes rows.
Park writes with approval_required
Reads can allow. A production DELETE should deny or park. Needed INSERT / bounded UPDATE can return approval_required: the agent gets a structured wait state; the operator uses limetry_sql_list_pending or the approval API, then approves or rejects. Shipped policies/postgres.json is read-only — add require_approval_action_types to enable parked writes.
Point at your SQL database
Set DATABASE_URL to any SQL connection string your team trusts. Same classify → evaluate → allow/deny/approval_required.
Run dry-run before enforcement
Dry-run is the default (LIMETRY_SQL_DRY_RUN on unless set to "false"). Classify and evaluate without executing. Inspect audit, confirm SELECT allows and DDL denies, then set LIMETRY_SQL_DRY_RUN=false. Removing the variable keeps dry-run; it does not enable execution.
Store privacy-safe SQL audit
Audit (minimal by default) keeps sql.read / sql.write / sql.ddl, decision, timestamp, and enough statement shape to review — not full SQL at rest. A 200-character sql_preview in intent metadata is truncation, not redaction. Answer "was this a denied write?" without turning audit into a replayable log of predicates and values.
Operational notes
With the gate, that cleanup classifies as sql.write, evaluate returns deny, and The database never sees it. Agents retry; without the gate, speculative writes hit prod. Do not put DATABASE_URL in agent-visible MCP env.
CI starts a SQL service and a Limetry node: SELECT must allow and execute; DROP and DELETE must deny before the driver.
Related: The DELETE that erased 1,847 accounts and Adding write access without giving away the keys.
