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:
| Part | For an agent | Example |
|---|---|---|
| Principal | The user the agent acts for, plus the agent identity and the session | user ana (support), agent helpdesk-v3, run 81f2 |
| Action | The tool name, versioned | issue_refund |
| Resource | The object the call touches, resolved by the runtime | order 5521, owned by customer 77 |
| Arguments | Validated, typed call arguments | {"amount": 120} |
| Context | Facts about the run: untrusted input seen, approvals given, time, channel | tainted=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.
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_byset. 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:
| Case | Request | Expected |
|---|---|---|
| 1 | Ana reads her own calendar | allow |
| 2 | Ana reads Bo's calendar | deny (default) |
| 3 | Ana refunds 20 | allow + log_amount |
| 4 | Ana refunds 120, no approval | require approval |
| 5 | Ana refunds 120, approved by Lee | allow |
| 6 | Ana refunds 900, approved by Lee | deny (cap beats approval) |
| 7 | Cy refunds 5 | deny (no role) |
| 8 | Tainted run emails an outside address, approved | deny |
| 9 | Clean run emails an outside address | require approval |
| 10 | Refund with the amount argument missing | deny (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
| Choice | Gain | Cost |
|---|---|---|
| Embedded PDP library | Microsecond decisions, no network dependency | Policy rollout needs a deploy or hot reload |
| Central PDP service | One policy for many agents, central logs | Network hop; outage must deny |
| Rules in code | Fast to start | Hard to review, easy to scatter |
| Policy language (Cedar, Rego) | Reviewable, analysable, testable alone | New language, attribute plumbing |
| Broad approval effect | Safe default for risky calls | Fatigue 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
- List every tool and, for each, the arguments and resource that decide whether a call is acceptable.
- Define the request schema: principal pair, action, resolved resource, typed arguments, run context.
- Route every tool call through one PEP that holds the credentials and denies on error.
- 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.
- Move rules into Cedar or Rego once there are more than a handful, with forbid overrides permit and default deny.
- Add require-approval and obligations, and make the PEP deny unknown obligations.
- Ship policy changes in shadow mode, review disagreements, then enforce.
- Log every decision with rule names and policy version, and alert on denial spikes per tool.