Security

Routing Agent Actions by Risk Tier With a Policy File

Antonio Evans8 min read

A plain white desk with a laptop open to a YAML policy file.
Key points

Key points

  1. 1A policy file maps every tool, amount band and target pattern to one of three tiers: auto, single or dual approval.
  2. 2Load and validate the policy once at startup, and refuse to start if any tool lacks a default tier.
  3. 3Enforce the decision at one choke point that wraps every tool call, so the model never chooses its own approval tier.
  4. 4Test each tier with representative actions plus edge cases like missing amount fields and ambiguous targets.

Why a policy file beats a hardcoded allowlist

An agent that can call tools is a different security problem from a chatbot. As the Frontier Model Forum puts it, the capabilities that make agents useful also create risks that existing cybersecurity frameworks were not designed for, especially misaligned actions taken outside the intended scope of a deployment (Frontier Model Forum). The dangerous combination is well documented as the lethal trifecta: access to private data, exposure to untrusted content, and the ability to communicate externally all at once (Simon Willison). An agent inside that combination can be talked into exfiltrating data by instructions hidden in content it reads.

Approval gates alone do not fix prompt injection. What they do is bound the damage: a consequential action cannot execute until a named person says yes. The OWASP cheat sheet is blunt about the mechanism. Instructions in a prompt are not a control; authorization must be enforced in the execution component, outside the agent's context, and unknown tools or missing approval requirements should fail closed (OWASP AI Agent Security Cheat Sheet).

A policy file has three practical advantages over if statements scattered through your code:

  1. It is reviewable. A security reviewer can read the YAML and see that payments.transfer above 5,000 needs two approvals. They cannot audit 40 branches across 12 files.
  2. It is testable. You can write a table of actions and expected tiers, and a failing test points at the policy, not at model behavior.
  3. It is one enforcement point. Every tool call goes through one wrapper, which means there is exactly one place to instrument, log and fix.

That last point matters for audit. A defensible trail needs the policy outcome recorded alongside the actor, the target and the arguments, because a policy decision you did not log is a decision you cannot explain later (Digital Applied).

The three tiers, and how to choose them

Keep the tiers small. The useful distinction is not a score from 1 to 10, it is what happens next: run, pause for one person, or pause for two. Arthur's governance writeup makes the same point differently, arguing that the line should be found per action, not per agent, so the same agent might draft emails freely, need approval above a refund threshold and be blocked from closing accounts entirely (Arthur AI).

TierMeaningTypical actionsReviewer countFailure mode if overused
autoExecute immediately, log onlyReads, drafts, small payments to known vendors0Silent damage from a wrong action
singlePause, one named approverMid-size payments, first payment to a new vendor, reversible production changes1Approver fatigue, rubber-stamping
dualPause, two independent approversLarge payments, new bank details, IAM changes, destructive production actions2Latency, workflows that stall on missing approvers

The division between single and dual is where most teams go wrong, and the reason is fatigue. If a gate fires so often that reviewers click approve without reading, you have the appearance of oversight without the substance, which is worse than no gate because it manufactures a record that says someone looked (Arthur AI). Dual approval should be rare enough that a reviewer sees one a week, not one an hour.

A working decision rule, borrowed from how finance teams already tier budget approvals (the Oracle example routes budgets above 20,000 through three management levels rather than one) is to set the single-approval band where you are willing to accept a wrong action and reverse it, and the dual band only above the amount where reversal is expensive or impossible (Oracle).

The policy file

Here is the schema. Three top-level keys: version, defaults and tools. Every tool must appear under tools, and defaults supplies the fallback when a tool has no bands matching the call.

version: 1
defaults:
  tier: single          # any tool without bands lands here
  reason: "unclassified action"

# Amount bands are inclusive on both ends and evaluated top down.
tools:
  payments.transfer:
    amount_field: amount_cents      # where to read the number from
    bands:
      - max: 5000                   # 0 to 5000 cents
        tier: auto
        target_required: false
      - max: 250000                 # 5000 to 250000 cents
        tier: single
        target_required: true
      - tier: dual                  # no max means "and above"
        target_required: true
    target_rules:
      - pattern: "^(vendor|invoice):[a-z0-9_-]+$"
        tier: auto                  # known target, no escalation
      - pattern: "^(acct|iban):"
        tier: dual                  # new account details always dual
        reason: "new destination account"

  crm.update_record:
    amount_field: null
    bands:
      - tier: auto

  infra.run_command:
    amount_field: null
    bands:
      - tier: single
        target_rules:
          - pattern: "^prod-"
            tier: dual
            reason: "production host"

  files.read:
    amount_field: null
    bands:
      - tier: auto

Four details make this usable in practice:

Bands are ordered and closed at the top. The first band whose max is at or above the amount wins. A band with no max is the catch-all and must be last. This lets you express "0 to 5,000 auto, 5,000 to 250,000 single, above that dual" without writing ranges twice and risking a gap.

Target rules escalate, never de-escalate. A target rule can raise the tier above what the band chose, but it can never lower it. Otherwise a payment of 300,000 cents to a pattern marked auto would slip through at auto. This one-way rule is the single most important property in the file, and it should be an explicit comment so nobody "optimizes" it later.

amount_field is declared, not guessed. If a tool carries a number, name the field. If it does not, set amount_field: null and the band logic is skipped entirely, which means the tool's band must not depend on an amount. A missing amount for a tool that declares an amount_field is an error, not a zero.

Every rule can carry a reason. The reason string goes into the approval request and the audit log. A reviewer who sees "new destination account" knows what to check; a reviewer who sees only "policy requires approval" learns to click through.

The parser and startup validation

The parser does two jobs: load the file, and refuse to run if the loaded policy is unsafe. Fail closed is the default the GitLab approval policy docs also choose: if rules are invalid or cannot be evaluated, approval is required rather than skipped (GitLab). Your loader should behave the same way, with the extra step of stopping the process rather than degrading quietly.

import re, yaml
from dataclasses import dataclass, field
from enum import Enum

class Tier(str, Enum):
    AUTO = "auto"
    SINGLE = "single"
    DUAL = "dual"

RANK = {Tier.AUTO: 0, Tier.SINGLE: 1, Tier.DUAL: 2}

class PolicyError(Exception):
    pass

@dataclass
class Band:
    max: int | None
    tier: Tier
    target_required: bool
    target_rules: list = field(default_factory=list)

@dataclass
class ToolPolicy:
    name: str
    amount_field: str | None
    bands: list

def load_policy(path: str) -> dict[str, ToolPolicy]:
    with open(path) as fh:
        raw = yaml.safe_load(fh)

    if raw.get("version") != 1:
        raise PolicyError(f"unsupported policy version: {raw.get('version')}")

    default_tier = raw["defaults"]["tier"]
    if default_tier not in Tier._value2member_map_:
        raise PolicyError(f"bad default tier: {default_tier}")

    tools = {}
    for name, spec in (raw.get("tools") or {}).items():
        bands_raw = spec.get("bands") or []
        if not bands_raw:
            raise PolicyError(f"{name}: no bands and no default tier")

        last = bands_raw[-1]
        if "max" in last:
            # catch-all must be open-ended, otherwise amounts above it
            # would silently fall through to no band at all
            raise PolicyError(f"{name}: final band must omit max")

        bands = []
        for i, b in enumerate(bands_raw):
            if b["tier"] not in Tier._value2member_map_:
                raise PolicyError(f"{name}: unknown tier {b['tier']}")
            if i < len(bands_raw) - 1 and "max" not in b:
                raise PolicyError(f"{name}: band {i} has no max but is not last")
            prev = bands_raw[i - 1]["max"] if i else 0
            if "max" in b and b["max"] <= prev:
                raise PolicyError(f"{name}: band {i} max not increasing")

            rules = []
            for r in b.get("target_rules") or []:
                try:
                    rules.append((re.compile(r["pattern"]), Tier(r["tier"]), r.get("reason")))
                except re.error as exc:
                    raise PolicyError(f"{name}: bad pattern {r['pattern']}: {exc}")
            bands.append(Band(b.get("max"), Tier(b["tier"]),
                              bool(b.get("target_required", False)), rules))

        tools[name] = ToolPolicy(name, spec.get("amount_field"), bands)

    if not tools:
        raise PolicyError("policy contains no tools")

    return {"default_tier": Tier(default_tier), "tools": tools}

The validation checks are the interesting part, because each one corresponds to a real failure:

  • Final band must be open-ended. Without this, a 900,000-cent payment falls past the last band and the code either crashes mid-run or, worse, treats "no band" as auto.
  • Max values must strictly increase. A copy-paste error that duplicates a max creates a band that can never match, and the reviewer never learns the intended tier was skipped.
  • Every regex must compile. A typo in a pattern that silently matched nothing would leave production-host commands at single approval.
  • Tiers are validated against the enum at load time, not at call time. The first bad call in production is the wrong time to discover a typo.
  • Tools-only keys. If a tool the agent can call is absent from the file, the wrapper falls back to defaults.tier. Log at startup which tools the agent actually registered and which of those have no explicit entry, and treat a missing entry as a review item, not an outage.

The single enforcement choke point

Every tool call, whether from the model, from a sub-agent or from a retry, passes through one function. That function resolves the tier, and if the tier is not auto it writes a pending approval record and leaves the tool unexecuted. This mirrors the pattern that approval must be checked and consumed in one atomic step immediately before execution, because a user_confirmed flag passed in the arguments is not a control (OWASP).

def resolve_tier(policy, tool_name: str, args: dict) -> tuple[Tier, str]:
    tp = policy["tools"].get(tool_name)
    if tp is None:
        return policy["default_tier"], "tool not in policy"

    if tp.amount_field:
        if tp.amount_field not in args:
            raise PolicyError(f"{tool_name}: missing {tp.amount_field}")
        amount = args[tp.amount_field]
        if not isinstance(amount, int) or amount < 0:
            raise PolicyError(f"{tool_name}: bad amount {amount!r}")
    else:
        amount = None

    chosen = None
    for band in tp.bands:
        if amount is None or band.max is None or amount <= band.max:
            chosen = band
            break

    tier, reason = chosen.tier, "amount band"
    target = args.get("target") or args.get("host") or ""

    if chosen.target_required and not target:
        raise PolicyError(f"{tool_name}: target required")

    for pattern, rule_tier, rule_reason in chosen.target_rules:
        if pattern.search(target) and RANK[rule_tier] > RANK[tier]:
            tier, reason = rule_tier, rule_reason or "target rule"

    return tier, reason


def call_tool(agent_ctx, tool_name, args, registry, policy, approvals):
    tier, reason = resolve_tier(policy, tool_name, args)

    record = agent_ctx.audit(
        actor=agent_ctx.principal, tool=tool_name, args=args,
        policy_tier=tier.value, reason=reason,
    )

    if tier is Tier.AUTO:
        return registry[tool_name](**args)

    pending = approvals.create(call_id=record.id, tool=tool_name, args=args,
                               required=1 if tier is Tier.SINGLE else 2,
                               reason=reason, approvers=[])
    return {"status": "pending_approval", "call_id": pending.id, "tier": tier.value}

Three properties to notice, because they are what make the wrapper a choke point rather than a suggestion:

The model never receives the tier. The tool's return value for a gated call is pending_approval. The model cannot talk itself into auto approval because there is nothing in its context that controls the decision. It can ask for the tool again with different arguments, and each attempt is resolved fresh.

Approvals bind to a specific call. The pending record stores the exact arguments. If the model retries with a different target or a larger amount, that is a new call with a new record and a new decision. An approval that can be reused against changed arguments is not an approval. OWASP's phrasing is that changes to the target or parameters require new approval (OWASP).

Missing timeouts deny. When no approver responds, the call must not proceed. Approval that times out into execution is a gate that does not exist (Arthur AI). And when the approval does arrive hours later, re-check the amount and target against current state before executing, because a price that was acceptable at pause time may not be at resume time (Scalekit).

One more piece the model cannot supply: the approver identity. Store who approved, when, and which call, so the audit record answers "who said yes" rather than "the system approved it." Runlayer's audit trail guidance is explicit that human approval entries need the reviewer's identity, the timestamp, the risk level that prompted the gate and the outcome, because without them the trail cannot distinguish oversight from automation (Runlayer).

Tests that prove each tier routes correctly

Policy code fails in boring ways: a band that never matches, a pattern that matches too much, a missing field that crashes mid-workflow. A table-driven test fixes this cheaply. Write one test per row of a table of (tool, arguments, expected tier), and add a separate test per validation rule that asserts startup fails.

import pytest

@pytest.fixture(scope="module")
def policy():
    return load_policy("policy.yaml")

CASES = [
    # small payment to a known vendor: auto
    ("payments.transfer", {"amount_cents": 4200, "target": "vendor:acme"}, "auto"),
    # band boundary: 5000 exactly is still auto
    ("payments.transfer", {"amount_cents": 5000, "target": "vendor:acme"}, "auto"),
    # one cent over the boundary: single
    ("payments.transfer", {"amount_cents": 5001, "target": "vendor:acme"}, "single"),
    # large payment to a known vendor: dual, because the band catches all above
    ("payments.transfer", {"amount_cents": 900000, "target": "vendor:acme"}, "dual"),
    # small payment to a new account: escalates to dual via target rule
    ("payments.transfer", {"amount_cents": 4200, "target": "iban:DE00"}, "dual"),
    # read-only: always auto
    ("files.read", {"path": "/reports/q1.csv"}, "auto"),
    # admin action on staging: single
    ("infra.run_command", {"cmd": "restart", "host": "stg-api-1"}, "single"),
    # admin action on production: dual via target rule
    ("infra.run_command", {"cmd": "restart", "host": "prod-api-1"}, "dual"),
]

@pytest.mark.parametrize("tool,args,expected", CASES)
def test_tier_routes(policy, tool, args, expected):
    tier, _ = resolve_tier(policy, tool, args)
    assert tier.value == expected

def test_unknown_tool_uses_default(policy):
    tier, reason = resolve_tier(policy, "shell.exec", {"cmd": "ls"})
    assert tier.value == "single"
    assert reason == "tool not in policy"

@pytest.mark.parametrize("args", [{}, {"amount_cents": None}, {"amount_cents": "5000"}])
def test_missing_or_bad_amount_fails_closed(policy, args):
    with pytest.raises(PolicyError):
        resolve_tier(policy, "payments.transfer", args)

def test_target_required_when_band_says_so(policy):
    with pytest.raises(PolicyError):
        resolve_tier(policy, "payments.transfer", {"amount_cents": 10000})

def test_target_rule_cannot_lower_tier(policy):
    # 300000 cents is dual by band; a target rule marked auto must not demote it
    tier, _ = resolve_tier(policy, "payments.transfer",
                           {"amount_cents": 300000, "target": "vendor:acme"})
    assert tier.value == "dual"

The ambiguous-target case deserves its own test, because it is where regex policies leak. Consider a tool that accepts both a target and a host, with a pattern like ^prod-. A host named prod-replica.staging.internal matches the pattern and gets dual approval, which is over-strict but safe. A host named replica-prod-1 does not match, which is the dangerous direction. Anchoring matters more than most reviewers expect. Add a test with a deliberately misleading target and assert the tier you actually want, then fix the pattern rather than the test.

Then test the wrapper, not just the resolver. Two assertions catch the most common implementation bug:

  • A gated call does not execute the tool. Stub the registry with a function that raises if called, pass a 10,000-cent payment, and assert the tool was never invoked and a pending record exists.
  • A retry with changed arguments gets a fresh record. Approve the first call, then call again with a different target and assert the wrapper created a second pending record rather than reusing the approval.

Rollout and the review loop that keeps it honest

A policy file that nobody changes is a policy file that is wrong. Two signals tell you when to adjust it, and both come from the logs you already write at the choke point.

The first is dual-approval volume per week. If dual gates fire more often than reviewers can read carefully, narrow the band that produces them, and if they fire zero times over a quarter for a tool that can move money or credentials, check whether the tool is actually being called. The second is rubber-stamping: approvals that arrive in under a few seconds, or from one reviewer for nearly every request, are evidence the threshold is set wrong rather than evidence the policy works (Arthur AI).

It is also worth auditing what the agent can reach, not just what the policy gates. Hatchworks' checklist frames this as treating agents like identities: a unique ID per agent, least privilege on tools, and monitoring of what the agent actually does, on the basis that an agent reading untrusted content and holding broad tool access produces authorized actions with the wrong intent (Hatchworks). A policy file bounds the blast radius of a manipulated agent; it does not remove the manipulation.

If you are running several agents across a team and want one shared policy, one audit trail and one approval queue rather than a copy per project, that is the kind of setup a platform decision rather than a code decision, and a short discovery call is usually faster than reading twelve vendor pages: Quanta Consulting.

FAQ

FAQ

How many tiers should the policy have?

Three is usually enough: auto, single and dual. More tiers create bands that overlap and reviewers who have to remember which level they are. The distinction that matters operationally is how many people must sign off before a side effect happens, not a numeric risk score.

What happens when a tool is missing from the policy file?

The call falls back to the file's default tier, and the wrapper logs the tool name as an unclassified action. Set the default to single or dual so a new tool is gated until someone writes a band for it, and review the unclassified list on a schedule so the fallback stays rare.

Can a target pattern lower a tier set by an amount band?

No, and it should be a hard rule in the code. Target rules can only escalate, because a demotion would let a large payment to a pattern marked auto execute without review. Test this explicitly with a large amount and a low-tier pattern.

How do I handle an approval that arrives after the arguments changed?

Treat it as a new decision. Bind each approval record to the exact arguments of the call it was requested for, and re-check the amount and target against current state immediately before execution. If either changed, discard the approval and create a new pending request.

Does a gated tool call block the whole agent run?

It depends on your runtime, but the safer default is to end the current turn with a pending status and resume when a decision exists. That keeps the agent from running ahead on partial information, and it means the approval flow has durable state that survives a restart.

Sources

Sources

  1. Emerging Security Practices for AI Agents - Frontier Model Forum
  2. The lethal trifecta for AI agents: private data, untrusted content, and ...
  3. AI Agent Security - OWASP Cheat Sheet Series
  4. Agent Audit Trail Design: 7 Best Practices for 2026
  5. Human-in-the-Loop Governance for AI Agents - Arthur AI
  6. Using the Supervisory List Builder for Multilevel Approval ...
  7. Merge request approval policies
  8. Human-in-the-Loop Tool Calling: Approval Gates for AI ...
  9. AI agent audit trails: audit log best practices
  10. AI Agent Security Checklist: Identity, Least Privilege, ...
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles