Skip to content

Chapter 4: Conditional Policies

In Chapters 1-3 each policy stood on its own โ€” one file, one evaluator, one decision. But in a real company, policies come from different places. The security team writes company-wide rules. Individual teams write rules for their own agents. When those policies disagree, the system needs a way to pick a winner.

What you'll learn:

Section Topic
The problem Why the same tool needs different rules in different contexts
Environment-aware rules Same tool, different answer in dev vs prod
Two policies, one agent A global security policy and a team-level policy
Spot the conflict Loading both and seeing where they disagree
Conflict resolver Four strategies for picking a winner
Scopes How organizational hierarchy affects the outcome
Try it yourself Exercises

The problem

In Chapters 1โ€“3, every rule applied the same way everywhere. But in a real company, context matters. An agent that can freely call tools in a dev environment should be locked down in production. And when the security team and a product team write separate policies for the same agent, those policies can disagree.

This chapter covers both problems: environment-aware rules (dev vs prod) and conflict resolution (what happens when two policies disagree).


Step 1: Environment-aware rules

The simplest conditional policy: the same tool gets a different answer depending on the environment.

The policy (04_env_policy.yaml)

version: "1.0"
name: environment-policy
description: Rules that change based on the deployment environment

rules:
  - name: block-production
    condition:
      field: environment
      operator: eq
      value: production
    action: deny
    priority: 100
    message: "Production environment: all agent actions require approval"

  - name: allow-development
    condition:
      field: environment
      operator: eq
      value: development
    action: allow
    priority: 90
    message: "Development environment: agents can act freely"

defaults:
  action: deny
  max_tool_calls: 5

The key idea: the condition checks the environment field, not the tool name. The same agent hitting the same tool gets a different answer depending on where it runs.

Evaluating it

from agent_os.policies import PolicyEvaluator
from agent_os.policies.schema import PolicyDocument

policy = PolicyDocument.from_yaml("04_env_policy.yaml")
evaluator = PolicyEvaluator(policies=[policy])

dev = evaluator.evaluate({"environment": "development"})
prod = evaluator.evaluate({"environment": "production"})

print(dev.allowed)   # True  โ€” dev is open
print(prod.allowed)  # False โ€” production is locked down

Example output

  Environment      Decision     Reason
  -------------------------------------------------------
  development      โœ… allow     Development environment: agents can act freely
  staging          ๐Ÿšซ deny      No rules matched; default action applied
  production       ๐Ÿšซ deny      Production environment: all agent actions require approval

Notice that staging falls through to the default (deny) because no rule matches it. You decide whether that's safe-by-default or an oversight โ€” either way, it's explicit.

When is this enough?

Environment-aware rules work when one team writes all the rules. But in a real company, the security team writes company-wide rules and individual teams write rules for their own agents. When those policies disagree about the same tool, the system needs a way to pick a winner.


Step 2: Two policies, one agent

A company has a security team that writes rules for all agents. They decide: "No agent should send emails without controls." They update the company-wide policy to block send_email.

But the customer support team pushes back: "Our agent's entire job is emailing customers. If you block send_email, our agent is useless."

Both teams have valid reasons. The security team is protecting the company. The support team needs their agent to work. Their policies now disagree about the same tool.

This is not a hypothetical problem. In any organization, broad security rules and specific team needs will clash. The question is: who gets the final say?

Global security policy (04_global_policy.yaml)

Written by the security team. Applies to every agent in the company.

version: "1.0"
name: global-security-policy
description: Company-wide rules set by the security team

rules:
  - name: block-delete-database
    condition:
      field: tool_name
      operator: eq
      value: delete_database
    action: deny
    priority: 100
    message: "Company policy: no agent may delete databases"

  - name: block-send-email
    condition:
      field: tool_name
      operator: eq
      value: send_email
    action: deny
    priority: 90
    message: "Company policy: agents may not send emails without controls"

defaults:
  action: allow
  max_tool_calls: 10

Two tools are blocked. Everything else โ€” including write_file and search_documents โ€” is allowed by default.

Support team policy (04_support_team_policy.yaml)

Written by the support team lead. Applies only to the support team's agent.

version: "1.0"
name: support-team-policy
description: Rules for the customer support team's agent

rules:
  - name: allow-send-email
    condition:
      field: tool_name
      operator: eq
      value: send_email
    action: allow
    priority: 90
    message: "Support team: our agent needs to email customers"

  - name: block-write-file
    condition:
      field: tool_name
      operator: eq
      value: write_file
    action: deny
    priority: 80
    message: "Support team: our agent does not need to write files"

  - name: block-delete-database
    condition:
      field: tool_name
      operator: eq
      value: delete_database
    action: deny
    priority: 100
    message: "Support team: deleting databases is never allowed"

defaults:
  action: allow
  max_tool_calls: 20

The support team explicitly allows send_email (their agent needs it), blocks write_file (not needed), and blocks delete_database (obviously dangerous). Everything else is allowed.

Where they agree and disagree

Tool Global policy Team policy Conflict?
send_email deny allow Yes โ€” security says no, support says yes
write_file allow (default) deny Yes โ€” opposite directions
delete_database deny deny No โ€” both agree
search_documents allow (default) allow (default) No โ€” both agree

Two out of four tools are in conflict. The interesting one is send_email โ€” a deliberate disagreement between two parts of the organization.


Step 3: Spot the conflict

Load both policies and evaluate each tool against each one separately:

from agent_os.policies import PolicyEvaluator
from agent_os.policies.schema import PolicyDocument

global_policy = PolicyDocument.from_yaml("04_global_policy.yaml")
team_policy = PolicyDocument.from_yaml("04_support_team_policy.yaml")

global_eval = PolicyEvaluator(policies=[global_policy])
team_eval = PolicyEvaluator(policies=[team_policy])

g = global_eval.evaluate({"tool_name": "send_email"})
t = team_eval.evaluate({"tool_name": "send_email"})

print(g.allowed)  # False โ€” global says deny
print(t.allowed)  # True  โ€” team says allow

Both policies have an opinion about send_email, and they disagree. Loading them both into a single evaluator would merge the rules and pick one based on priority โ€” but that is fragile and depends on which rule happens to come first. We need a deliberate strategy.


Step 4: Resolve the conflict

PolicyConflictResolver takes conflicting decisions and picks a winner based on a strategy you choose.

First, wrap each policy's decision into a CandidateDecision โ€” a container that carries the decision along with metadata about where it came from:

from agent_os.policies import (
    CandidateDecision,
    ConflictResolutionStrategy,
    PolicyConflictResolver,
    PolicyScope,
)

# The global policy is company-wide โ†’ scope is GLOBAL.
global_candidate = CandidateDecision(
    action="deny",
    priority=90,
    scope=PolicyScope.GLOBAL,
    policy_name="global-security-policy",
    rule_name="block-send-email",
    reason="Company policy: agents may not send emails without controls",
)

# The team policy is for one team โ†’ scope is TENANT.
team_candidate = CandidateDecision(
    action="allow",
    priority=90,
    scope=PolicyScope.TENANT,
    policy_name="support-team-policy",
    rule_name="allow-send-email",
    reason="Support team: our agent needs to email customers",
)

Now resolve with a strategy:

resolver = PolicyConflictResolver(ConflictResolutionStrategy.DENY_OVERRIDES)
result = resolver.resolve([global_candidate, team_candidate])

print(result.winning_decision.action)       # "deny"
print(result.winning_decision.policy_name)  # "global-security-policy"
print(result.conflict_detected)             # True

The four strategies

Strategy How it works Who wins the send_email conflict?
DENY_OVERRIDES If anything says deny, deny wins Security team (deny)
ALLOW_OVERRIDES If anything says allow, allow wins Support team (allow)
PRIORITY_FIRST_MATCH Highest priority number wins; ties keep input order Security team (deny) โ€” listed first, same priority
MOST_SPECIFIC_WINS More specific scope wins Support team โ€” TENANT beats GLOBAL

Running all four:

DENY_OVERRIDES            ๐Ÿšซ deny   (from global-security-policy)
ALLOW_OVERRIDES           โœ… allow  (from support-team-policy)
PRIORITY_FIRST_MATCH      ๐Ÿšซ deny   (from global-security-policy)
MOST_SPECIFIC_WINS        โœ… allow  (from support-team-policy)

Same conflict, different outcomes. The strategy is a business decision:

  • DENY_OVERRIDES means the security team always has veto power. This is the safest option โ€” no team can override a company-wide block.
  • ALLOW_OVERRIDES is the opposite โ€” any explicit allow punches through a deny. Useful for exception-based governance.
  • PRIORITY_FIRST_MATCH picks the highest priority number. When priorities tie (both are 90 here), the resolver falls back to input order โ€” whichever candidate appears first in the list wins. The security policy was listed first, so it wins. If you swapped the order, the support team would win instead. This makes ties fragile, which is why you should avoid giving competing rules the same priority.
  • MOST_SPECIFIC_WINS means the team closest to the agent gets the final say. This is more flexible โ€” teams can grant exceptions for their own agents.

Most companies pick one strategy and use it consistently. There is no universally "right" answer โ€” it depends on how much control the security team wants versus how much autonomy the teams need.


Step 5: Scopes

Each CandidateDecision has a scope label that says how broad the policy is:

GLOBAL (0)  โ†’  TENANT (1)  โ†’  ORGANIZATION (2)  โ†’  AGENT (3)
broadest                                            most specific

Think of it like a company org chart:

  • GLOBAL = a rule from the CEO that applies to everyone
  • TENANT = a rule from a division VP that applies to their division
  • ORGANIZATION = a rule from a department manager within that division
  • AGENT = a rule written for one specific agent

When using MOST_SPECIFIC_WINS, the more specific scope always wins. If two candidates have the same scope, priority breaks the tie.

Example: Same scope, priority breaks the tie

What if the security team writes an agent-specific policy too?

# Security upgrades to AGENT scope with priority 95.
# Support stays at AGENT scope with priority 90.

resolver = PolicyConflictResolver(ConflictResolutionStrategy.MOST_SPECIFIC_WINS)
# Security priority: 95, Support priority: 90
# Same scope โ†’ higher priority wins โ†’ security wins โ†’ deny

When both policies are at the same scope level, specificity can't break the tie โ€” so the higher priority number wins, just like PRIORITY_FIRST_MATCH.


Full example

python docs/tutorials/policy-as-code/examples/04_conditional_policies.py
============================================================
  Chapter 4: Conditional Policies
============================================================

--- Part 1: Environment-aware rules ---

  Environment      Decision     Reason
  -------------------------------------------------------
  development      โœ… allow     Development environment: agents can act freely
  staging          ๐Ÿšซ deny      No rules matched; default action applied
  production       ๐Ÿšซ deny      Production environment: all agent actions require approval

  Same agent, same tool โ€” different answer depending on
  where it runs. Dev is open, production is locked down.

  This works when one team writes all the rules. But what
  happens when the security team and a product team each
  write their own policy โ€” and they disagree?

--- Part 2: Two policies, one agent ---

  Tool                   Global policy      Team policy
  ----------------------------------------------------------
  send_email             ๐Ÿšซ deny             โœ… allow  โš ๏ธ CONFLICT
  write_file             โœ… allow            ๐Ÿšซ deny   โš ๏ธ CONFLICT
  delete_database        ๐Ÿšซ deny             ๐Ÿšซ deny
  search_documents       โœ… allow            โœ… allow

  The security team says: deny send_email.
  The support team says: allow send_email.
  Both policies apply to the same agent. Who wins?

--- Part 3: Resolving the send_email conflict ---

  Security team says: deny   send_email  (scope=global)
  Support team says:  allow  send_email  (scope=tenant)

--- Part 4: Four strategies, four outcomes ---

  DENY_OVERRIDES
    Result: ๐Ÿšซ deny   (from global-security-policy)
    Why:    If anyone says deny, the answer is deny. Safety first.

  ALLOW_OVERRIDES
    Result: โœ… allow  (from support-team-policy)
    Why:    If anyone says allow, the answer is allow. Exceptions win.

  PRIORITY_FIRST_MATCH
    Result: ๐Ÿšซ deny   (from global-security-policy)
    Why:    Highest priority number wins. Both are 90 โ€” tie goes to whichever candidate was listed first.

  MOST_SPECIFIC_WINS
    Result: โœ… allow  (from support-team-policy)
    Why:    The more specific scope wins. TENANT beats GLOBAL.

--- Part 5: The scope hierarchy ---

  Scopes rank from broadest to most specific:

    GLOBAL (0)  โ†’  TENANT (1)  โ†’  ORGANIZATION (2)  โ†’  AGENT (3)

  With MOST_SPECIFIC_WINS, a closer scope always beats a broader one.

  Scenario A: security=GLOBAL vs support=TENANT
    Winner: support-team-policy (โœ… allow)
    TENANT (specificity 1) beats GLOBAL (specificity 0)

  Scenario B: security=GLOBAL vs support=AGENT
    Winner: support-team-policy (โœ… allow)
    AGENT (specificity 3) beats GLOBAL (specificity 0)

  Scenario C: both at AGENT scope (tie on scope โ€” priority breaks it)
    Security priority: 95, Support priority: 90
    Winner: global-security-policy (๐Ÿšซ deny)
    Same scope, so higher priority wins.

============================================================
  Conflict resolution lets you layer policies from different
  parts of the organization without them breaking each other.
============================================================

How does it work?

Here is what happens inside resolver.resolve(candidates):

  Security team:  deny  send_email  (scope=GLOBAL, priority=90)
  Support team:   allow send_email  (scope=TENANT, priority=90)
        โ”‚
        โ–ผ
  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
  โ”‚  Which strategy?             โ”‚
  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
             โ”‚
   โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
   โ–ผ         โ–ผ         โ–ผ                  โ–ผ
 DENY_     ALLOW_    PRIORITY_         MOST_
 OVERRIDES OVERRIDES FIRST_MATCH       SPECIFIC_WINS
   โ”‚         โ”‚         โ”‚                  โ”‚
   โ–ผ         โ–ผ         โ–ผ                  โ–ผ
 Any deny? Any allow? Highest          Highest
 Yes โ†’ deny Yes โ†’ allow priority?      specificity?
                      Both 90 (tie)    TENANT > GLOBAL
   โ”‚         โ”‚         โ”‚                  โ”‚
   โ–ผ         โ–ผ         โ–ผ                  โ–ผ
 ๐Ÿšซ deny   โœ… allow  ๐Ÿšซ deny          โœ… allow

The candidates stay the same. Only the strategy changes the outcome.


Try it yourself

  1. Resolve the write_file conflict. The global policy allows write_file (by default) and the team policy denies it. Create two CandidateDecision objects for this conflict and resolve with DENY_OVERRIDES. Who wins?

  2. Add a third policy. Imagine a department-level policy (scope ORGANIZATION) that denies send_email. Now you have three candidates: GLOBAL (deny), ORGANIZATION (deny), and TENANT (allow). Resolve with MOST_SPECIFIC_WINS. Does the support team still win?

  3. Change the priority. Give the security team's block-send-email rule priority 95 (higher than the team's 90). Re-run with PRIORITY_FIRST_MATCH. Now security wins โ€” because its number is bigger.


What's missing?

We can now layer policies from different parts of the organization and resolve conflicts between them. But some actions are too important for any automatic decision. Deleting a customer's account, transferring money, or sending a mass email โ€” these are actions where neither "allow" nor "deny" is the right automatic answer. You want a human to review and approve before the agent proceeds.

Previous: Chapter 3 โ€” Rate Limiting Next: Chapter 5 โ€” Approval Workflows โ€” route dangerous actions to a human before execution.