An agent policy is a function (tool, input, user) → allow | deny | ask. Test it like any other pure function. You do not need to spin up a model. You do not need to hit production tools. If your tests only run the LLM and eyeball the transcript, you are not testing the policy — you are sampling the model.
Three layers, cheapest first:
- Decision table. For each row, call the same function your runtime will call, with
commit: false/ no side effects, and assert the effect. - Name lint. A typo'd tool pattern (
emial.send) is dead code. Diff rule patterns against the actual tool map. - One integration case that proves a deny happened before the tool function ran (a boolean flag inside
executethat must stay false).
The naive approach (and when it is sufficient)
import { PolicyEngine, matchTool } from 'nominee'
const engine = new PolicyEngine([{ rules, fallback: 'deny' }])
const cases = [
{ tool: 'refund.issue', input: { amount: 25 }, user: 'alice', want: 'allow' },
{ tool: 'refund.issue', input: { amount: 200 }, user: 'alice', want: 'ask' },
{ tool: 'refund.issue', input: { amount: 2000 }, user: 'alice', want: 'deny' },
{ tool: 'customers.export', input: {}, user: 'alice', want: 'deny' },
]
for (const row of cases) {
const got = await engine.evaluate(
{ tool: row.tool, input: row.input, user: row.user },
{ commit: false },
)
expect(got.effect).toBe(row.want)
}
That table is enough when the policy is a handful of rules in one file, one agent, no delegation chain, and no when predicate that calls the network. evaluate(..., { commit: false }) (or nominee.check) does not consume allow-budgets. Keep the table next to the policy. Run it in CI. Ship. A plain if/else decide function is fine too — give it user and assert the returned effect the same way.
Add the lint when you have more than a dozen tools or anyone has already shipped a silent misspelling:
const tools = Object.keys(rawTools)
for (const rule of rules) {
for (const pattern of rule.tools) {
const hit = tools.some((t) => matchTool(pattern, t))
if (!hit) throw new Error(`unreachable: ${pattern}`)
}
}
Do not execute when in the lint. You do not know the input shape. The table covers predicates; the lint covers names.
What this does not catch
- Shadowing.
allow('*')listed beforedeny('customers.export')means the deny never runs. First-match-wins needs an ordered fixture, not just names. - Budgets.
allow('search.*', { max: 20 })needs 21 calls against the same user to see the escalate-to-ask. - Authorizer drift. If a
resourceis checked by your FGA/OPA at plan and at execute, a unit table on the local rules will not see a revoked tuple. Mock the authorizer and assert fail-closed.
If your “policy” is a prompt (“never delete production”), stop. Prompts are not testable in this sense. Put the deny in code, then table-drive it.
When nominee is a fit (and when it is not)
nominee.check({ tool, input, user }) is the dry run: same engine, commit: false, no receipts, no budgets consumed. Official adapters still execute through run(); tests should call check() or PolicyEngine.evaluate directly.
npx nominee-cli check policy.mjs lints default-exported rules against a built-in sample list (email.read, github.merge_pr, …). A perfectly good allow('refund.issue') is reported unreachable against that list — that is a limitation of the CLI samples, not of your policy. Prefer the Object.keys lint above for product tool names.
Skip nominee when a native framework permission callback already is the whole policy and you are happy testing that callback. Skip it when the agent only reads public, non-sensitive data through already-quota-protected APIs. Skip it if you wanted OPA/FGA as the decision point — test those engines with their own fixtures, and keep nominee (if you use it) as the enforcement seam. Deeper split: FGA and OPA decide. nominee executes.
See it for yourself
Four refunds. One policy. CI-shaped.