An agent turns model output into actions: send this email, refund that order, read this calendar. Each action is a tool call, and each tool call is an authorization question. Most agent code answers it with scattered if statements inside tool functions. That works for one tool and fails for fifty: nobody can say what the agent is allowed to do, rules contradict each other, and a change to one tool silently widens another.

This page is about the decision model: how to state that question precisely, where to answer it, which answers exist besides yes and no, and how to test the answers. It uses the classic split into enforcement, decision, information and administration points, builds a small decision engine in Python with a decision table, expresses the same rules in Cedar, and covers shadow mode, logs and failure modes. Related pages cover the neighbouring problems: delegated identity in the confused deputy, attenuable credentials in capability tokens, and the call gateway in tool abuse.

The question every tool call asks

Write the request down before writing any rule. A tool-call authorization request has five parts:

PartFor an agentExample
PrincipalThe user the agent acts for, plus the agent identity and the sessionuser ana (support), agent helpdesk-v3, run 81f2
ActionThe tool name, versionedissue_refund
ResourceThe object the call touches, resolved by the runtimeorder 5521, owned by customer 77
ArgumentsValidated, typed call arguments{"amount": 120}
ContextFacts about the run: untrusted input seen, approvals given, time, channeltainted=false, approved_by=null

Two details matter. The principal is a pair, user and agent, because an agent should hold at most the intersection of what the user may do and what the agent was built to do. And the resource must be resolved by trusted code (look the order up, read its owner), not copied from model output, because the model can be told to lie about whose order it is.

Enforcement, decision, information and administration

The split comes from XACML-era access control and fits agents well. The policy enforcement point (PEP) sits in the agent runtime where tool calls are dispatched; it builds the request, asks, and obeys. The policy decision point (PDP) is a pure function from request to decision. The policy information point (PIP) supplies attributes the request does not carry, such as roles and owners. The policy administration point (PAP) is where policy is written, reviewed and versioned, usually a git repository with CI.

Tool-use authorization split into enforcement, decision, information and administration pointsModelproposes tool callPEPin the agent runtimeTool / APIruns only if allowedcallallowed callPDPpure function: request to decisionrequestdecisionPIProles, owners, run labelsattributesApproverhuman, when requiredapprovalPAPpolicy repo, review, testsversioned policyDecision logrequest, effect, rule, versionThe PEP never decides; the PDP never acts. Both facts are what make the design testable.
The PEP in the agent runtime asks; the PDP decides from the request and PIP attributes; policy arrives versioned from the PAP; every decision is logged.

The PEP must be impossible to route around: the model calls tools only through it, tool credentials live behind it, and an error inside it denies. Keeping the PDP pure (no network calls, no side effects) is what makes the rest of this page possible: decisions can be unit-tested, replayed from logs and run twice in shadow mode.

Effects beyond allow and deny

Allow and deny are not enough for agents. Two more effects earn their place:

  • Require approval: the call is acceptable if a named human agrees. The PEP pauses the run, shows the call and its arguments, and re-asks with approved_by set. Approval does not bypass policy: a hard cap stays a deny even when approved. Rendering the prompt well is its own subject; see permission prompt patterns.
  • Allow with obligations: the call may run, but the PEP must do something as well, such as redact a field, log the amount, or strip attachments. Obligations the PEP does not understand must turn the decision into a deny, or a new obligation silently becomes optional.

Then fix the combining rule. Use deny overrides: any matching deny wins, then any approval requirement, then any allow, and if nothing matches, deny. Default deny means a new tool is unusable until someone writes a rule for it, which is what you want.

The PEP side of each effect is short, and it is where most bugs hide, so write it once and reuse it for every tool:

from dataclasses import replace

KNOWN_OBLIGATIONS = {"log_amount": log_amount, "redact_pii": redact_pii}

def dispatch(req, tool_fn, ask_human):
    d = decide(req)
    if d.effect is Effect.APPROVE:
        approver = ask_human(req)               # shows tool, resolved resource, arguments
        if approver is None:
            return deny(req, "approval refused")
        d = decide(replace(req, approved_by=approver))   # re-decide; never skip policy
    if d.effect is not Effect.ALLOW:
        return deny(req, d.rule)
    if any(o not in KNOWN_OBLIGATIONS for o in d.obligations):
        return deny(req, "unknown obligation")
    for o in d.obligations:
        req = KNOWN_OBLIGATIONS[o](req)
    log_decision(req, d)
    return tool_fn(**req.args)

Note the second call to decide after approval. The approved request goes through the whole policy again, so a forbid rule that matches it still wins, and the approval itself is recorded as context rather than treated as a bypass.

A decision engine in Python

A complete engine fits in a page. Rules are data; decide is a pure function:

from dataclasses import dataclass, field
from enum import Enum

class Effect(Enum):
    ALLOW = "allow"
    DENY = "deny"
    APPROVE = "require_approval"

@dataclass(frozen=True)
class Request:
    user: str
    user_roles: frozenset
    agent: str
    tool: str
    args: dict
    resource_owner: str | None = None
    tainted: bool = False           # untrusted content entered this run
    approved_by: str | None = None  # set by the PEP after a human approves

@dataclass
class Decision:
    effect: Effect
    rule: str
    obligations: list = field(default_factory=list)

@dataclass
class Rule:
    name: str
    effect: Effect
    tools: set
    when: callable
    obligations: tuple = ()

RULES = [
    Rule("no-external-mail-when-tainted", Effect.DENY, {"send_email"},
         lambda r: r.tainted and not r.args["to"].endswith("@acme.example")),
    Rule("refund-cap", Effect.DENY, {"issue_refund"}, lambda r: r.args["amount"] > 500),
    Rule("big-refund-needs-human", Effect.APPROVE, {"issue_refund"},
         lambda r: r.args["amount"] > 50 and r.approved_by is None),
    Rule("external-mail-needs-human", Effect.APPROVE, {"send_email"},
         lambda r: not r.args["to"].endswith("@acme.example") and r.approved_by is None),
    Rule("read-own-calendar", Effect.ALLOW, {"read_calendar"},
         lambda r: r.resource_owner == r.user),
    Rule("support-refunds", Effect.ALLOW, {"issue_refund"},
         lambda r: "support" in r.user_roles, ("log_amount",)),
    Rule("mail", Effect.ALLOW, {"send_email"}, lambda r: True),
]
PRECEDENCE = [Effect.DENY, Effect.APPROVE, Effect.ALLOW]

def decide(req: Request) -> Decision:
    matched = {e: [] for e in PRECEDENCE}
    for rule in RULES:
        if req.tool in rule.tools:
            try:
                hit = rule.when(req)
            except (KeyError, TypeError, AttributeError):
                return Decision(Effect.DENY, f"error-in:{rule.name}")   # fail closed
            if hit:
                matched[rule.effect].append(rule)
    for effect in PRECEDENCE:
        if matched[effect]:
            obligations = [o for r in matched[effect] for o in r.obligations]
            return Decision(effect, ",".join(r.name for r in matched[effect]), obligations)
    return Decision(Effect.DENY, "default-deny")

Note what is not there: no reference to the prompt, the model's stated reason, or anything the model can phrase persuasively. Inputs are identities, typed arguments, resolved owners and run facts.

The same policy in Cedar

Lambdas are fine for a prototype. In production, keep policy in a language built for it, so security reviewers can read it without reading Python and tools can analyse it. Cedar (open-sourced by AWS) has exactly the semantics above: default deny, and forbid overrides permit.

forbid (principal, action == Action::"issue_refund", resource)
when { context.amount > 500 };

permit (principal in Role::"support", action == Action::"issue_refund", resource)
when { context.amount <= 50 || context has approved_by };

permit (principal, action == Action::"read_calendar", resource)
when { resource.owner == principal };

Cedar has only permit and forbid, so approval is derived by the PEP: if a request is denied, re-evaluate it with an approval attribute added; if that is permitted, the effect is require approval, otherwise plain deny. Open Policy Agent with Rego is the other common choice; it is more general and lets a rule return a structured decision with obligations directly.

Worked example: a helpdesk agent as a decision table

Take a helpdesk agent with three tools and two users: Ana in support, Cy without roles. Write the policy as a decision table first, then make it executable, so the table is the test suite:

CaseRequestExpected
1Ana reads her own calendarallow
2Ana reads Bo's calendardeny (default)
3Ana refunds 20allow + log_amount
4Ana refunds 120, no approvalrequire approval
5Ana refunds 120, approved by Leeallow
6Ana refunds 900, approved by Leedeny (cap beats approval)
7Cy refunds 5deny (no role)
8Tainted run emails an outside address, approveddeny
9Clean run emails an outside addressrequire approval
10Refund with the amount argument missingdeny (fail closed)

Running these against the engine above passes all ten (twelve in the full suite, including an unknown tool). Cases 6 and 8 are the important ones: they prove that an approval cannot launder a forbidden call, which is the property an injected run will probe. Case 8 also shows taint-aware rules: once untrusted content has entered a run, external email is off the table even with a click.

Shadow mode, decision logs and latency

Roll out new policy in shadow mode: the PEP evaluates old and new versions, enforces the old one, and logs every disagreement. A week of disagreements read by a human catches the rule that would have blocked every Monday payroll run. Log each decision as a structured record with the request (arguments redacted where needed), effect, matching rule names, obligations and policy version; that record answers both "why was this blocked?" and "who approved this?". Alert on spikes in denials per tool, which mean either an attack or a broken rule.

Latency is rarely the problem for an embedded PDP; evaluation takes microseconds, while the tool call takes hundreds of milliseconds. Fetching PIP attributes is the cost. Cache roles briefly, but never cache decisions across runs, because context such as taint and approvals differs per run.

Failure modes

  • Authorizing the tool, not the call. "The agent may use issue_refund" says nothing about amount or order owner; rules must see arguments and resolved resources.
  • Model-supplied facts. Taking the owner or the user's role from the model's arguments lets an injection assert them.
  • Fail open. A rule that throws on a missing field must deny, as in case 10.
  • Approval as override. If approval skips the forbid rules, an attacker only needs a tired user.
  • Unknown obligations ignored. The PEP must deny on any obligation it cannot perform.
  • Policy drift. Rules edited in a console without review; keep the PAP in git with the decision table in CI.

Trade-offs

ChoiceGainCost
Embedded PDP libraryMicrosecond decisions, no network dependencyPolicy rollout needs a deploy or hot reload
Central PDP serviceOne policy for many agents, central logsNetwork hop; outage must deny
Rules in codeFast to startHard to review, easy to scatter
Policy language (Cedar, Rego)Reviewable, analysable, testable aloneNew language, attribute plumbing
Broad approval effectSafe default for risky callsFatigue if it fires too often

Authorization decides whether a call happens; it does not undo one. Pair it with the reversibility classification in reversibility by design so the expensive approvals land on calls that cannot be taken back.

What to do next

  1. List every tool and, for each, the arguments and resource that decide whether a call is acceptable.
  2. Define the request schema: principal pair, action, resolved resource, typed arguments, run context.
  3. Route every tool call through one PEP that holds the credentials and denies on error.
  4. Write the policy as a decision table with ten to twenty cases, including approval-cannot-override and missing-argument cases, and run it in CI.
  5. Move rules into Cedar or Rego once there are more than a handful, with forbid overrides permit and default deny.
  6. Add require-approval and obligations, and make the PEP deny unknown obligations.
  7. Ship policy changes in shadow mode, review disagreements, then enforce.
  8. Log every decision with rule names and policy version, and alert on denial spikes per tool.
Key takeaway: Treat every tool call as an authorization request with a principal pair, an action, a resolved resource, typed arguments and run context. Enforce in one PEP that cannot be bypassed, decide in a pure deny-overrides PDP with default deny, add require-approval and obligations, and never let approval override a forbid. Write the policy as a decision table that runs in CI, roll changes out in shadow mode, and log every decision with its rule and version.