← Blog

How to test an agent policy: table-drive the decision, then lint the names.

I work at Auth0. nominee's core is zero-dependency and provider-neutral. You can run every technique on this page against a plain function; the library is optional.

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:

  1. Decision table. For each row, call the same function your runtime will call, with commit: false / no side effects, and assert the effect.
  2. Name lint. A typo'd tool pattern (emial.send) is dead code. Diff rule patterns against the actual tool map.
  3. One integration case that proves a deny happened before the tool function ran (a boolean flag inside execute that 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 before deny('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 resource is 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.

$ npx nominee-cli no signup
Star on GitHub nominee-cli check → Read the docs →