A runtime safety layer between AI agents and Postgres. Developer preview.
Give Claude, Codex, or another AI coding agent direct database access and a
single bad statement can wipe a table. DELETE FROM clients with no WHERE.
An UPDATE that was meant for one row but hits a million. A stray semicolon
that turns one scoped delete into a full-table one. Database permissions don't
help here — they answer "is this role allowed to touch this table," not "how
much will this particular statement change, and can I take it back?"
Interdict answers those two questions, on every statement, before damage is done:
- It measures the real impact before running. For a risky write it actually simulates the statement in a throwaway transaction and reports the count — "this DELETE would affect 2,300,000 rows" — then asks for confirmation instead of just running it. We call that number the statement's blast radius.
- It makes writes undoable. Every write it allows is recorded so you can reverse it with one command, with a full audit trail of who did what. Writes it can't safely record are blocked rather than run-and-hope.
- It explains every block. A blocked statement comes back with a reason code and a suggested fix — readable by a human, and machine-readable so an agent can correct itself and retry.
The checks that decide block / allow / confirm are deterministic and fast (microseconds), so normal traffic isn't slowed down. Anything fuzzy (an optional LLM "does this match the stated task?" check) is advisory only and never sits in the path of a query you're waiting on.
> UPDATE accounts SET balance = 0 ⛔ blocked: no WHERE — would hit every row
fix: add a WHERE that scopes it
> UPDATE accounts SET balance = 0 WHERE id = 1 ✓ UPDATE 1 (undo id 3811adb4)
> DELETE FROM accounts WHERE balance < 2000 ⚠ confirm: would delete 19 rows [y/n]
> \undo ✓ reverted — 1 row restored
Install Interdict from PyPI:
pip install interdict-dbStart the Interdict MCP server:
AGENT_DB_DSN=postgresql://postgres:postgres@localhost:5433/pagila \
AGENT_OPERATOR_TOKEN="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')" \
interdictThis is the main product surface: your agent talks to Interdict's MCP server instead of touching Postgres directly. Interdict is active in an agent chat only when that MCP server is connected for the chat.
The user does not need to phrase every request as "use Interdict." Once the MCP server is connected, your agent instructions should say that any database work inside a larger task must go through Interdict's MCP tools.
Claude Code:
claude mcp add interdict \
--env AGENT_DB_DSN=postgresql://postgres:postgres@localhost:5433/pagila \
--env AGENT_OPERATOR_TOKEN="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')" \
-- interdictCodex (CLI, or edit ~/.codex/config.toml):
codex mcp add interdict \
--env AGENT_DB_DSN=postgresql://postgres:postgres@localhost:5433/pagila \
--env AGENT_OPERATOR_TOKEN="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')" \
-- interdict# ~/.codex/config.toml (table is mcp_servers, with an underscore)
[mcp_servers.interdict]
command = "interdict"
[mcp_servers.interdict.env]
AGENT_DB_DSN = "postgresql://postgres:postgres@localhost:5433/pagila"
AGENT_OPERATOR_TOKEN = "paste-a-random-token-at-least-32-chars"Codex PATH gotcha: Codex may not inherit your shell's PATH. If it can't find
interdict, use the absolute path fromwhich interdictascommand. Verify withcodex mcp list, then/mcpin the Codex TUI.
To check whether Interdict is active in the current chat, ask the agent to call
interdict_status.
A held write is approved out-of-band with approve_query and the operator
token, which the agent should not see.
Interdict also ships a Rich terminal console for local demos and manual SQL. It is not the primary agent workflow.
interdict-tuiIn the console, every statement goes through the same safety engine first. Safe reads and scoped writes just run. A risky write is simulated and shown before anything happens:
interdict ▸ DELETE FROM clients WHERE active = true
╭─ ⚠ CONFIRM WRITE ─────────────────────────────╮
│ DELETE FROM clients WHERE active = true │
│ Blast radius: 2,300,000 rows (precise) │
│ Reversible: yes — an undo id will be kept │
╰────────────────────────────────────────────────╯
Execute? [y/n] (n):
Press y to run it, n to cancel. Nothing touched the database until you said
yes. After a write runs you get an undo id; \undo reverses it.
Because you wrote the SQL, a block is advice, not a wall — \override runs a
blocked statement anyway (after a confirmation, fully audited, and still undoable
when the statement's shape allows).
Commands:
| Command | What it does |
|---|---|
| (any SQL) | run it through the safety layer |
\undo |
reverse the most recent write |
\revert <id> |
reverse a specific write by its undo id |
\override |
run the last blocked statement anyway (your call) |
\stats |
what the layer has caught for you (see below) |
\history |
this session's executed writes |
\tables |
tables the policy allows |
\help · \quit |
help · leave |
See your savings any time with \stats (or interdict-stats from the shell):
statements guarded, blocked, held for confirmation, overrides, reverts, and the
largest blast radius it held back.
Why this beats keeping a
.log/dump backup. A backup is the whole database, slow to restore, and anonymous. Here every write is recorded per-action and attributed, so you undo one mistaken statement instantly by its id — instead of restoring the entire database and losing everyone else's work since the last dump.
Interdict needs a Postgres to talk to. The repo ships a seeded one:
docker compose up -d # seeded Postgres on localhost:5433 (Pagila + large tables)First start generates ~5M rows (1–2 min); later starts are instant. Point at any
other database with AGENT_DB_DSN. Re-seed from scratch with
docker compose down -v && docker compose up -d.
AI agent ──(MCP)──> [Interdict adapter] ──> [SAFETY ENGINE] ──> Postgres
│ │
optional local SQL console ──────┘ │
parse → classify → policy → (simulate?) → decide
│ (record undo on writes)
async: audit log, advisory intent check
- The engine (
engine/) is a standalone, transport-agnostic core. It parses each statement to a real Postgres AST withpglast(never string matching, so comments, casing, whitespace, alias stars, and wrapped writes likeEXPLAIN ANALYZE DELETE …can't smuggle anything past), classifies it, checks it against a declarative YAML policy, and — only for a risky write — simulates the blast radius with a time-boxedBEGIN; … ; ROLLBACK. - Adapters are thin renderers over that engine. The MCP server is the main
product surface; the optional
richterminal console uses the same gate for local demos/manual checks. Policy logic never lives in an adapter. - The hot path stays cheap. Only blocking-vs-allowing is on it (in-memory,
microseconds). Simulation is opt-in, gated to risky writes, and time-boxed
(
statement_timeout+lock_timeout). Audit logging and the optional LLM intent check are async/out-of-band. Writes fail closed on uncertainty; reads fail open so the layer can never take down read availability.
It is not "git for databases," a migration tool, a semantic layer, or a replacement for Postgres roles/RLS — those still do their job; this does the part they structurally can't.
The hard constraint is don't slow down the database — a safety layer that adds latency gets ripped out. Budget: added p99 < 5 ms on the pass-through path, enforced by a local benchmark gate.
| What | Result |
|---|---|
| Hot-path cost, warm (parse-cache hit) | 2.6 µs p50 / 2.7 µs p99 |
| Hot-path cost, cold (first sight of a query) | 166 µs p50 / 189 µs p99 |
| End-to-end overhead vs direct asyncpg | ≈ 0 ms p50 & p99 — gate PASS |
| Red corpus blocked (false negatives) | 40 statements, 0% |
| Green corpus allowed (false positives) | 18 statements, 0% |
| Blast-radius accuracy (precise path) | exact affected-row count |
| Undo round-trip | ~4 ms, conflict-checked, exact restore |
| Automated tests | 308 |
Full methodology and per-rate tables: benchmarks/RESULTS.md
and benchmarks/METRICS.md.
Environment variables (used by both modes):
| Variable | Default | Purpose |
|---|---|---|
AGENT_DB_DSN |
postgresql://postgres:postgres@localhost:5433/pagila |
Target Postgres. Default is local-dev only. |
AGENT_POLICY |
policies/default.yaml |
YAML policy loaded at startup. |
AGENT_AUDIT_LOG |
logs/audit.jsonl |
Async JSONL audit log (also feeds \stats). Raw SQL/task text is redacted; hashes are kept for correlation. |
AGENT_OPERATOR_TOKEN |
unset | Required to approve held writes via MCP. Must be a random token of at least 32 characters. |
AGENT_POOL_MIN / AGENT_POOL_MAX |
1 / 10 |
asyncpg pool sizing. |
MCP tools the server exposes:
| Tool | Purpose |
|---|---|
run_query(sql, stated_task?) |
Classify, policy-check, simulate if risky, then execute or block. |
list_pending_approvals() |
Writes currently held for operator approval. |
approve_query(approval_id, operator_token) |
Execute a held write when the token matches. |
revert_write(action_id, operator_token?) |
Revert a recorded write. |
audit_status() |
Audit queue depth, dropped-record count, log path. |
Kept visible on purpose:
- Semantic correctness is undecidable in general. We catch blast-radius and scope-contradiction cases and make the rest reversible; we don't claim to catch every "valid SQL but wrong" statement.
BEGIN/ROLLBACKsimulation can't undo external side effects (triggers calling out, already-consumed sequences) and takes locks — hence the gating and time-boxing.- Reversibility isn't infinite (external calls, cascades, consumed sequences).
Shapes that can't be recorded for safe undo are blocked by default;
local evaluation can opt out with
undo.block_non_reversible: false. - Audit logging is non-blocking: under overload it drops records rather than
stalling queries (
audit_statusreports this), and the local JSONL log isn't tamper-proof. - LLM intent checks are advisory only — never the last line of defense, never on the hot path.
- This is a local developer preview, not a production recipe. Use a least-privilege Postgres role and review your policy before pointing it at real data.
Before putting Interdict in front of a real database:
- Publish a privacy policy if you collect or process user data. Know which tables may contain personal data, where audit logs live, and who can read them.
- Use a dedicated least-privilege Postgres role for
AGENT_DB_DSN; do not use a superuser or owner role in production. If your application uses Supabase, keep Row Level Security policies on application-facing tables. Interdict is a guardrail, not a replacement for database authorization. - Keep all secrets server-side. Never expose
AGENT_DB_DSN,AGENT_OPERATOR_TOKEN, provider API keys, or PyPI tokens in browser code, client bundles, screenshots, logs, or docs. - Use a random
AGENT_OPERATOR_TOKENof at least 32 characters. Short configured tokens are rejected. - If you wrap Interdict in an HTTP API, add authentication, per-user and per-IP rate limits, strict CORS for your domains only, security headers, and CAPTCHA such as Cloudflare Turnstile on public forms. The bundled MCP server runs over stdio and does not provide a public web endpoint.
- Validate requests on the server. Client-side checks are only UX.
- Show generic database errors to users or agents. Keep diagnostics on the server side; Interdict returns generic database execution failures and redacts raw SQL/task text from its JSONL audit log.
- Test the failure paths: invalid DB credentials, nonexistent tables, duplicate approval attempts, wrong operator token, expired or repeated confirmation flows, blocked SQL, and undo conflicts.
- Review changes against the current OWASP Top 10 and OWASP API Security Top 10 if you add a hosted web/API surface.
engine/ # safety core: parse, classify, policy, simulate, undo, audit, intent, session
adapters/ # mcp_server.py (agent layer), tui.py (optional local console)
policies/ # declarative YAML policy files
corpus/ # red (should-block) + green (should-allow) query sets
benchmarks/ # latency harness, RESULTS.md, METRICS.md, CI latency gate
examples/ # runnable end-to-end demo
db/ # Docker Postgres seed scripts (Pagila + large tables)
tests/ # pytest suite (308 tests)