Permission Design for AI Agents
Let an agent read production but never write. We built a read-only SQL wrapper and checked how deny rules and hooks are evaluated.
PR: This article contains a placement for an affiliate program we plan to join (VPS hosting), chosen on price and region only.
Agents are useful when they can read production. They should not write to it. We checked Claude Code’s evaluation order in the official docs, then built and tested a read-only wrapper. Test environment: Linux, Claude Code 2.1.301, Python 3.13.5, SQLite 3.46.1, on 2026-10-16.
Evaluation order
From https://code.claude.com/docs/en/permissions (checked 2026-10-16):
- Rules are evaluated deny, then ask, then allow; the first match wins, regardless of specificity.
- A broad deny such as
Bash(aws *)beats a narrow allow such asBash(aws s3 ls). Allow can’t carve exceptions from deny. - A PreToolUse hook returning allow does not bypass matching deny or ask rules.
- A hook that exits 2 blocks before allow rules are considered.
- User-level allow plus project-level deny: deny wins.
You can stack blockers, but you can’t remove one by adding an allow. Start with broad denies and narrow allows.
Hook implementation and what slipped through: hooks in practice.
Don’t allow raw curl or psql
Allowing Bash(curl *) or Bash(psql *) to save prompts also allows writes to the same endpoint. We allow only the full path of a read-only wrapper.
A read-only wrapper, tested
#!/usr/bin/env python3
# /usr/local/bin/ro-sql (SQLite demo; in production, also connect with a read-only role)
import re, sys, sqlite3
sql = sys.argv[1].strip().rstrip(';')
WRITE = r'\b(insert|update|delete|drop|alter|create|replace|truncate|attach|pragma|vacuum|grant)\b'
if ';' in sql: sys.exit("reject: multiple statements")
if not re.match(r'(?is)^\s*(select|with)\b', sql): sys.exit("reject: not SELECT/WITH")
if re.search(WRITE, sql, re.I): sys.exit("reject: write keyword")
db = sqlite3.connect('file:demo.db?mode=ro', uri=True)
for row in db.execute(sql): print(row)
| Input | Result |
|---|---|
SELECT id, selected_at FROM jobs | ran |
select * from jobs where deleted_flag=0 | ran |
WITH x AS (SELECT 1) SELECT * FROM x | ran |
SELECT 1; DELETE FROM jobs | rejected (multiple statements) |
DELETE FROM jobs | rejected (not SELECT/WITH) |
select * from jobs where 'delete' = 'x' | rejected (write keyword) |
Word boundaries keep selected_at and deleted_flag working. A literal 'delete' is rejected; that errs on the safe side, so we left it.
{
"permissions": {
"allow": ["Bash(/usr/local/bin/ro-sql *)"],
"deny": ["Bash(psql *)", "Bash(curl *)"]
}
}
Wrapping the tool in a shell function or variable breaks prefix matching. Call it by its path.
A “harmless” probe deleted data
To see which HTTP methods an external API supported, we looped for m in GET POST PUT DELETE and deleted a real record. Probes now accept only GET, HEAD and OPTIONS. Read the OpenAPI spec first; test writes against dummy data you created.
Fix by adding, not removing
Auto mode’s classifier let a repair that installed an extra tool through, and blocked one that deleted a config line as weakening security. Look for the additive fix first.
Keep a human gate on unattended runs
Why keys and untrusted input must not share a process is covered in our VPS setup article. One step further: an OS keychain that the shell can read doesn’t help against that. Production writes happen only from an interactive terminal, gated by a passphrase a human types.
The quiet accidents that actually happened because an agent could write to production are collected in Deploy and Migration Accidents.
Allowing only the MCP tools you actually use is covered in How to Choose MCP Servers.
Summary
- Deny, then ask, then allow; allow can’t open a hole in deny.
- Never allow raw curl or psql; allow one read-only wrapper by full path.
- Limit probes to GET, HEAD and OPTIONS; test writes on dummy data.
- Don’t put keys and untrusted input in the same unattended process.