Issue 00 · Autumn 2026日本語 · RSS
AgentKoji

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):

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)
InputResult
SELECT id, selected_at FROM jobsran
select * from jobs where deleted_flag=0ran
WITH x AS (SELECT 1) SELECT * FROM xran
SELECT 1; DELETE FROM jobsrejected (multiple statements)
DELETE FROM jobsrejected (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