Why Your Agent Needs a Policy Engine
By James Han
·Apr 3, 2026
·3 min read
My agent deleted a .env file. Not maliciously — it was trying to clean up "unused configuration." The model saw a file it didn't understand, decided it wasn't needed, and removed it.
I didn't have a rule that said "don't touch .env files." I assumed the model would know better. It didn't.
Deny first, allow explicitly
The policy engine pattern is simple: every tool call passes through a permission check before execution. The check evaluates ordered rules. First match wins. Default is deny.
rules = [
{ tool: "*", path: ".env*", decision: DENY, reason: "env files protected" },
{ tool: "*", path: "*.key", decision: DENY, reason: "key files protected" },
{ tool: "run_command", cmd: /rm|DROP/, decision: DENY, reason: "destructive" },
{ tool: "git_push", decision: ASK, reason: "push needs approval" },
{ tool: "edit_file", path: "src/**", decision: ALLOW },
{ tool: "read_file", decision: ALLOW },
{ tool: "run_tests", decision: ALLOW },
]
// Default: DENY("no matching rule")
Three tiers of control: hook blocks (external scripts that can veto), static rules (the config above), and interactive approval (ask the human for risky actions).
Rules are data, not code
The most important design decision: policy rules live in a config file, not scattered as if-statements across the codebase. This means you can audit all permissions in one place, change them per project, and hand them to a new team member who can read them without understanding the runtime.
When I had permissions as code — if (tool === "git_push") askUser() — they were everywhere. Some tools had checks, some didn't. A new tool would get added without anyone thinking about permissions. With rules-as-data and a default-deny posture, a new tool with no rule is automatically blocked.
The model doesn't get to negotiate
A subtle failure mode: the model describes a dangerous action in innocent terms. "I'll quickly reorganize the project structure" might mean mv src/* new-src/. If your policy layer evaluates the model's description instead of the actual tool call, it gets manipulated.
The policy engine evaluates the tool call — tool name, arguments, paths. Not the model's narrative about why the call is necessary. The call either matches a rule or it doesn't. No persuasion.
The anti-pattern: session-wide approval
A user approves one git_push. The model pushes five times during the session, each time assuming the first approval covers all subsequent pushes. Each call should be independently evaluated. Approval is per-call, not per-session.
The takeaway
Your policy engine is the only thing standing between your agent and a mistake you can't undo. Make it deny-first, rule-based, and immune to the model's persuasion.
I write about this when I have something worth saying.