Security

A Policy File for Routing Agent Actions by Risk Tier

Antonio Evans9 min read

A single YAML policy file displayed on a screen, with three labeled columns branching from it: a red blocked column with a stop sign, an amber approval column with a person icon.
Key points

Key points

  1. 1A policy file lists wildcard-matched action patterns and assigns each one a tier: blocked, log, or approve, so the agent runtime never guesses.
  2. 2A deny-by-default gate loads the file at the start of each run and refuses any action that no rule matches, which closes the silent-permission gap.
  3. 3Approval-tier rules pause the agent and wait for a human decision, recording the approver, the request context, and the outcome.
  4. 4Unit tests import the same file the runtime reads, so a tier change breaks the test suite instead of quietly widening what the agent can do.

Why an intentions document is not a control

Most teams write agent safety as prose. A system prompt says the agent must not touch billing records. A wiki page says refunds need approval. A security review says production changes are proposal-only. None of that is read at the moment of execution, and all of it drifts the first time someone edits the prompt. The OWASP Top 10 for LLM Applications puts excessive agency at position six, describing exactly the failure where an LLM system is granted more autonomy and more connected permissions than the task requires (OWASP). A prompt line is a suggestion the model follows most of the time and ignores when it matters most; tests on commercial models show that reframing an attack as a fictional scenario defeats a "never discuss X" instruction with significant success (Kalvium Labs).

Policy-as-code is the established answer to this class of problem outside AI: rules expressed in a human-readable declarative format, versioned alongside application code, evaluated by a policy engine, and checked in CI/CD before deployment (Wiz). For agents, the same pattern applies at the runtime boundary, with a policy decision point placed before retrieval, tool invocation, workflow writes and privileged operations, so that model output is treated as a request for authorization rather than authorization itself (Thomas Thelliez).

The distinction that matters is enforcement versus documentation. A policy file that is only read by humans is a wiki page with YAML syntax. The same file becomes a control when three conditions hold: the runtime loads it fresh at the start of every run, unmatched actions are denied rather than allowed, and the test suite parses the identical file to build its expectations.

The three tiers and the decision rule that assigns them

Three tiers cover almost every practical case, and the assignment rule is simpler than most teams expect.

TierMeaning at runtimeAssign an action here whenOperator surface
BlockedRefuse before execution, raise a policy violation, log the attemptThe action is destructive, irreversible, or reaches a credential, payment rail, or external identityNone. Denial is final for this action
LogExecute, then write a structured audit event with redacted parametersReversible, low blast radius, or already governed by an upstream permission checkAudit stream only
ApprovePause, create a request, wait for a named human decision, then execute or refuseThe action moves money, changes production state, sends data to a third party, or touches personal data at scaleApproval queue with a timeout

The decision rule: classify by worst-case outcome, not by likelihood. If reversing the action requires a human, a backup restore, or a call to a customer, it belongs in approve or blocked. If the worst case is a bad line in a report that someone will notice by lunchtime, it belongs in log. This reverses the usual instinct, which is to ask "how likely is the agent to do this?" Attackers do not care about your likelihood estimate. The MiniScope work on least-privilege authorization for tool-calling agents makes the same point from a different angle: agents diverge from user intent because of indirect prompt injection, model unreliability, or ambiguity in the request, so permissions should be scoped to what the task requires and nothing broader (MiniScope).

A second rule prevents tier inflation. Before you add a new blocked rule, check whether the action should not be reachable at all. If your agent's identity cannot call the payments API, marking payments.refund as blocked is belt and braces, fine to keep but not your primary defense. Access control for LLM systems differs from traditional access control because models can take real actions through tools and API keys are often shared across a team, which erases per-agent accountability unless a layer above them reintroduces identity (Maxim AI). The policy file governs what the agent decides to do; the credentials govern what it is capable of doing. You need both, and they should not be the same document.

The schema

The file is YAML, checked into the same repository as the agent, with a version field, a default tier, and an ordered rule list. Order matters because the first matching rule wins.

# policy/agent-actions.yaml
api_version: policy.agent/v1
policy_version: 2026.02.1
default_tier: blocked
owner: platform-security
rules:
  - id: block-credential-access
    action: "secrets.*"
    tier: blocked
    reason: "Agents never read secret material directly."
    ref: SEC-114

  - id: block-destructive-db
    action: "database.drop*"
    tier: blocked
    reason: "Irreversible. No approval path."

  - id: log-read-ops
    action: "crm.contact.search"
    tier: log
    log_fields: [query_hash, result_count, tenant]
    redact: [email, phone]

  - id: approve-money-movement
    action: "billing.refund*"
    tier: approve
    conditions:
      amount_threshold_usd: 50
      when_below: log
    approvers: ["role:finance-ops"]
    timeout_minutes: 240
    escalate_to: ["role:controller"]
    log_fields: [order_id, amount, currency, requester, agent_run_id]

  - id: approve-external-send
    action: "email.send"
    tier: approve
    conditions:
      allow_domains: ["@ourcompany.com"]
      when_domain_allowed: log
    approvers: ["role:comms"]
    timeout_minutes: 60

Five schema decisions are worth locking down early.

Patterns, not enums. Action identifiers are dotted namespaces (billing.refund.create, crm.contact.export). Wildcards match one segment or trailing segments. Keep matching anchored so email.send does not accidentally match marketing.email.send_bulk; if you need prefix matching, write it explicitly as email.send* and audit how often that pattern fires.

Conditions narrow, never widen. A rule with conditions evaluates them against the request context. If the condition fails, the engine falls through to the next matching rule, or to the default tier. It never promotes an approve rule to log unless the rule says so explicitly, as when_below does above. This keeps the fall-through direction predictable.

Approvers are roles or groups, not individuals. People change teams. A rule naming a role survives that; a rule naming a person generates a policy that silently cannot be satisfied. The approver must not be the requesting principal, enforced in code, not in YAML.

Log fields are declared per rule. Blanket logging of all parameters sounds safer and is not: it writes personal data and credentials into a log store with weaker access controls than the systems the data came from. OWASP's guidance on access control for retrieval systems makes the related point that cross-context leakage and unauthorized embedding access are real risks that strict partitioning addresses (OWASP LLM08). Logging deserves the same discipline.

Every rule carries a reason and a reference. When an auditor asks why refunds above the threshold need finance approval, the YAML answers without a meeting. This is the same mechanism Git provides for infrastructure policy generally: every change is timestamped, versioned and reviewable, which is what makes continuous compliance auditable (Wiz).

Loading and enforcing at runtime

The gate is small: load, validate, compile patterns, then call it for every proposed action before execution. The critical implementation detail is where it sits. It must wrap the tool executor, not the model, because models do not follow instructions reliably and the executor is the only place that actually does anything.

import fnmatch
from dataclasses import dataclass

@dataclass(frozen=True)
class Decision:
    tier: str          # blocked | log | approve
    rule_id: str | None
    reason: str

class PolicyGate:
    def __init__(self, doc):
        if doc.get("api_version") != "policy.agent/v1":
            raise ValueError("unsupported policy api_version")
        self.version = doc["policy_version"]
        self.default = doc["default_tier"]
        self.rules = doc["rules"]

    def decide(self, action: str, context: dict) -> Decision:
        for rule in self.rules:
            if not fnmatch.fnmatchcase(action, rule["action"]):
                continue
            if self._conditions_hold(rule, context):
                return Decision(rule["tier"], rule["id"], rule["reason"])
            # condition failed: fall through, never widen
        return Decision(self.default, None, "no matching rule")

    def _conditions_hold(self, rule, context) -> bool:
        cond = rule.get("conditions") or {}
        if "amount_threshold_usd" in cond:
            return context.get("amount_usd", 0) > cond["amount_threshold_usd"]
        if "allow_domains" in cond:
            return context.get("recipient_domain") not in cond["allow_domains"]
        return True

def execute_action(gate, action, context, tool_call, propose_approval, emit_log):
    d = gate.decide(action, context)
    if d.tier == "blocked":
        emit_log("policy.blocked", action=action, rule=d.rule_id, policy=gate.version)
        raise PermissionError(f"{action} blocked by {d.rule_id}")
    if d.tier == "approve":
        token = propose_approval(action, context, d.rule_id)
        return {"status": "pending_approval", "token": token}
    emit_log("policy.allowed", action=action, rule=d.rule_id, policy=gate.version)
    return tool_call()

Three properties to verify in review. First, default_tier is blocked in production files, and a startup check refuses to boot if it is not. Second, the decision carries the policy version, so an audit event can be tied to the exact file that was in force. Third, approval returns a pending status rather than blocking a thread; the agent run should be resumable when the decision arrives, and the approval token must be single-use and bound to the exact action and context, so replaying it against a different amount fails.

The approval path deserves its own timeout behavior. When timeout_minutes elapses, the decision is deny, not allow. Silent expiry that resolves to the permissive option is the most common bug in first drafts of these systems, and it is the one that turns your approval gate into a slow automatic approval.

For tool registries that span models and providers, one useful simplification: put the gate at a single egress layer rather than reimplementing it per tool. Access control applied at a gateway covers identity, model and provider restrictions, tool permissions and consumption limits in one policy for every request, instead of each application reimplementing authentication and limits (Maxim AI).

Letting tests read the same file

The divergence failure is subtle. Runtime enforcement reads the YAML. Tests hardcode assertions like "refunds over 50 dollars need approval." Six months later someone raises the threshold to 500 and updates the YAML. The tests still pass. The behavior changed and nothing noticed. From then on your test suite documents a policy that no longer exists, and your reviewers trust it.

The fix is one loader function used by both paths.

def load_policy(path="policy/agent-actions.yaml"):
    with open(path) as f:
        return PolicyGate(yaml.safe_load(f))

Then the tests build their expectations from the file.

import pytest

@pytest.fixture(scope="session")
def gate():
    return load_policy()

@pytest.mark.parametrize("action", ["secrets.read", "database.drop_table"])
def test_blocked_actions_never_execute(gate, action):
    assert gate.decide(action, {}).tier == "blocked"

def test_refund_above_threshold_requires_approval(gate):
    rule = next(r for r in gate.rules if r["id"] == "approve-money-movement")
    threshold = rule["conditions"]["amount_threshold_usd"]
    above = {"amount_usd": threshold + 1}
    assert gate.decide("billing.refund.create", above).tier == "approve"

def test_unlisted_action_defaults_to_blocked(gate):
    assert gate.decide("internals.shell.exec", {}).tier == "blocked"

def test_approval_timeout_denies(gate):
    rule = next(r for r in gate.rules if r["id"] == "approve-money-movement")
    assert resolve_after_timeout(rule) == "deny"

Four test shapes cover most of the ground: blocked actions never execute, approval actions pause and return a pending token, unmatched actions fall to the default, and expired approvals deny. On top of those, add a drift test: a golden file of action names sampled from production traces, asserted to resolve to the tiers you expect. That test fails the moment someone adds a rule with a too-broad wildcard, which is the change most likely to widen access without anyone intending it.

Security testing in pipelines follows the same principle: checks belong where they produce feedback the developer can act on, with static analysis early and context-dependent checks later, and findings surfaced in pull requests rather than dashboards (Cycode). Treating your policy file like code under review gets you that for free, since the diff of a tier change is visible in the pull request.

A worked example, with numbers

A support operations agent handles refunds. The team logs actions for a month before enforcing anything, and the sample is 10,000 actions.

ActionObserved countAssigned tierApprovals triggered
crm.ticket.read7,400log0
crm.contact.search1,900log0
billing.refund.create (under 50 USD)480log0
billing.refund.create (50 USD or more)140approve140
billing.credit.issue60approve60
email.send (internal domain)18log0
email.send (external domain)2approve2
Very high value refunds over 5,000 USD0blocked0

The approval load is 202 decisions per month on a 10,000-action base, roughly 2 percent. That is a workable operator burden. Now the naive alternative: enforcing approval on every refund rather than only those above the threshold produces 620 approvals, triple the load, and most of them for amounts too small to justify human attention. Confirmation fatigue is not hypothetical; MiniScope reported a 43.4 to 89.4 percent reduction in simulated permission confirmations for cautious and typical personas under a role-based model compared with per-call prompting, with privilege-escalation attacks still mitigated (MiniScope). Thresholds are how you get that reduction without lowering the tier on the cases that matter.

Run the same arithmetic on your own trace before you finalize thresholds. If the approval column exceeds roughly five percent of daily actions for a single role, either the threshold is too low or you have an upstream permissions problem. Both are useful findings, and you get them before enforcement goes live rather than from an exhausted operator on day three.

Operating the file

Four practices keep the file trustworthy over time.

Version the content, not just the filename. policy_version appears in every audit event and in every approval request, so an incident review can reconstruct exactly which rules were in force. Tag releases; never edit a version in place after it has been enforced in production.

Review tier changes as security changes. Moving a rule from approve to log is a privilege expansion. Require a second reviewer from the security owner, the same way you would review a change to a firewall rule. Policy-as-code tooling exists precisely because manual oversight creates gaps that automated guardrails close at scale (Wiz).

Keep a break-glass path that is itself logged. Approvals time out, operators go home, incidents happen. A named role can force an action with an incident ticket reference; the action executes at the log tier and emits a distinct audit event. A blocked rule should not be breakable at all, which is a property of the code, not the YAML.

Audit the wildcards quarterly. Grep every rule for trailing wildcards, count how many distinct actions each matched over the last quarter, and reshuffle any rule that matched actions you did not intend. In an endpoint context the same problem appears as denylist-only policy: a block list can only name components already judged bad, while an approved connector can reach for credentials or push data to an unapproved destination without changing its identifier (Backslash Security). Allow by specific action, deny by default, and inspect the matches regularly.

One integration note. Where your agent calls different model types, keep the gate independent of the model. A single endpoint covering text, image, video, audio and embedding models with an OpenAI-compatible interface, as offered by Quanta Inference, means the policy layer governs the action regardless of which model proposed it, which is the property you want during a model swap.

FAQ

FAQ

Should the policy file be YAML or a general policy language like Rego?

YAML is enough when the thing being governed is a dotted action name with a handful of numeric and set conditions. Reach for a general policy language when you need decisions that combine multiple resources, relationships between principals, or logic you would otherwise bend YAML into expressing. A useful middle path: keep the tiers and patterns in YAML, and compile them into whatever engine you already run.

How is this different from the agent's tool permissions?

Tool permissions come from credentials and scopes and define what the agent is capable of calling at all. The policy file defines what the agent is allowed to do with those capabilities, at the moment it proposes an action, with the request context in hand. Use both: narrow credentials shrink the blast radius when the policy has a gap, and the policy catches actions the credentials technically permit but the task does not require, which is the excessive-agency failure mode.

What happens when the agent tries an action that matches no rule?

With a deny-by-default gate, the action is blocked and logged as a policy violation with the action name and the policy version. That log is the signal to write a new rule, since an unlisted action means either a gap in your coverage or a capability nobody approved. Never switch the default to allow to stop the alerts; that converts your gate into a wish.

Can I use one file for many agents?

Yes, with scoping. Add a scope field per rule (support, finance, build) or keep a per-role include that merges a shared base file with role-specific rules, and have the loader reject rules whose scope does not match the running agent. The risk to avoid is a large shared file whose wildcards were written for one agent and silently apply to another with wider reach.

How do I get started without a month of process design?

Turn on logging only. Set the default tier to log for a fixed period, run the agent's normal traffic, and review which actions appear. Then assign tiers from the observed list, set the default to blocked, and add the four test shapes. Because the tests import the same file, the day you flip the default is the day your suite proves the gate does what the YAML says.

Sources

Sources

  1. LLMRisks Archive - OWASP Gen AI Security Project
  2. LLM Guardrails That Actually Work in Production
  3. Policy as Code: Benefits, Examples, and How to Get Started
  4. Policy-as-Code for Enterprise AI Agents - Thomas THELLIEZ
  5. PDF A Least Privilege Framework for Authorizing Tool Calling Agents
  6. Understanding LLM Access Control - Maxim AI
  7. LLM08:2025 Vector and Embedding Weaknesses
  8. How to Integrate Security Testing into CI/CD Pipelines - Cycode
  9. How to Build Allowlist and Denylist Policies for AI Agents ...
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