Skip to content

Commit f99bc9c

Browse files
feat: policy conflict resolution with 4 declared strategies (#97)
- Add ConflictResolutionStrategy enum: DENY_OVERRIDES, ALLOW_OVERRIDES, PRIORITY_FIRST_MATCH, MOST_SPECIFIC_WINS - Add PolicyScope enum: GLOBAL, TENANT, AGENT with specificity ranking - Add PolicyConflictResolver with resolution trace for auditability - Add 'scope' field to Policy model (defaults to 'global') - Wire conflict resolution into PolicyEngine.evaluate() — collects ALL matching rules then resolves via configured strategy - Default strategy is PRIORITY_FIRST_MATCH (backward compatible with v1.0) - 25 tests covering all 4 strategies, edge cases, and engine integration - 54 existing policy tests continue to pass (Closes #91) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6c28ee5 commit f99bc9c

File tree

4 files changed

+676
-14
lines changed

4 files changed

+676
-14
lines changed

packages/agent-mesh/src/agentmesh/governance/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@
88
"""
99

1010
from .policy import PolicyEngine, Policy, PolicyRule, PolicyDecision
11+
from .conflict_resolution import (
12+
ConflictResolutionStrategy,
13+
PolicyScope,
14+
PolicyConflictResolver,
15+
CandidateDecision,
16+
ResolutionResult,
17+
)
1118
from .compliance import ComplianceEngine, ComplianceFramework, ComplianceReport
1219
from .audit import AuditLog, AuditEntry, AuditChain
1320
from .audit_backends import (
@@ -37,6 +44,11 @@
3744
"Policy",
3845
"PolicyRule",
3946
"PolicyDecision",
47+
"ConflictResolutionStrategy",
48+
"PolicyScope",
49+
"PolicyConflictResolver",
50+
"CandidateDecision",
51+
"ResolutionResult",
4052
"ComplianceEngine",
4153
"ComplianceFramework",
4254
"ComplianceReport",
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
"""
4+
Policy Conflict Resolution.
5+
6+
When multiple policies apply to the same agent action, the conflict
7+
resolution strategy determines which decision wins.
8+
9+
Strategies
10+
----------
11+
- **DENY_OVERRIDES** (safest): If ANY matching rule denies, the action
12+
is denied regardless of what other rules say. Standard in XACML and
13+
most enterprise policy systems.
14+
- **ALLOW_OVERRIDES**: If ANY matching rule allows, the action is
15+
allowed. Useful for exception-based governance where you want
16+
explicit allow-rules to punch through default-deny policies.
17+
- **PRIORITY_FIRST_MATCH** (current default): Rules are sorted by
18+
priority (highest first), and the first matching rule wins. This
19+
preserves backward compatibility with the existing PolicyEngine.
20+
- **MOST_SPECIFIC_WINS**: Agent-scoped rules override tenant-scoped,
21+
which override global-scoped. Within the same scope, priority breaks
22+
ties. Models the intuition that "closer policies override distant ones."
23+
24+
Scopes
25+
------
26+
Each ``Policy`` can declare a ``scope`` that indicates its breadth:
27+
28+
- ``global``: Organization-wide default policies
29+
- ``tenant``: Applied to a specific tenant or team
30+
- ``agent``: Applied to a specific agent instance
31+
32+
When ``MOST_SPECIFIC_WINS`` is active, scope determines precedence.
33+
When other strategies are active, scope is informational metadata.
34+
35+
Usage::
36+
37+
from agentmesh.governance.conflict_resolution import (
38+
ConflictResolutionStrategy,
39+
PolicyScope,
40+
PolicyConflictResolver,
41+
)
42+
43+
resolver = PolicyConflictResolver(ConflictResolutionStrategy.DENY_OVERRIDES)
44+
final = resolver.resolve(candidate_decisions)
45+
"""
46+
47+
from __future__ import annotations
48+
49+
import logging
50+
from enum import Enum
51+
from typing import Optional
52+
53+
from pydantic import BaseModel, Field
54+
55+
logger = logging.getLogger(__name__)
56+
57+
58+
class ConflictResolutionStrategy(str, Enum):
59+
"""Strategy for resolving conflicts between competing policy decisions."""
60+
61+
DENY_OVERRIDES = "deny_overrides"
62+
ALLOW_OVERRIDES = "allow_overrides"
63+
PRIORITY_FIRST_MATCH = "priority_first_match"
64+
MOST_SPECIFIC_WINS = "most_specific_wins"
65+
66+
67+
class PolicyScope(str, Enum):
68+
"""Breadth of a policy's applicability.
69+
70+
Specificity order (most → least): AGENT > TENANT > GLOBAL.
71+
"""
72+
73+
GLOBAL = "global"
74+
TENANT = "tenant"
75+
AGENT = "agent"
76+
77+
78+
# Specificity rank: higher = more specific
79+
_SCOPE_SPECIFICITY: dict[PolicyScope, int] = {
80+
PolicyScope.GLOBAL: 0,
81+
PolicyScope.TENANT: 1,
82+
PolicyScope.AGENT: 2,
83+
}
84+
85+
86+
class CandidateDecision(BaseModel):
87+
"""A single policy decision candidate awaiting conflict resolution.
88+
89+
Groups a decision with its originating policy metadata so the
90+
resolver can apply scope- and priority-aware strategies.
91+
92+
Attributes:
93+
action: The action the rule dictates (allow, deny, warn, etc.).
94+
priority: Numeric priority from the matched rule.
95+
scope: Scope of the policy that produced this decision.
96+
policy_name: Name of the originating policy.
97+
rule_name: Name of the matched rule.
98+
reason: Human-readable explanation.
99+
approvers: Required approvers for ``require_approval`` actions.
100+
"""
101+
102+
action: str
103+
priority: int = 0
104+
scope: PolicyScope = PolicyScope.GLOBAL
105+
policy_name: str = ""
106+
rule_name: str = ""
107+
reason: str = ""
108+
approvers: list[str] = Field(default_factory=list)
109+
110+
@property
111+
def is_deny(self) -> bool:
112+
return self.action == "deny"
113+
114+
@property
115+
def is_allow(self) -> bool:
116+
return self.action == "allow"
117+
118+
@property
119+
def specificity(self) -> int:
120+
return _SCOPE_SPECIFICITY.get(self.scope, 0)
121+
122+
123+
class ResolutionResult(BaseModel):
124+
"""Outcome of conflict resolution.
125+
126+
Attributes:
127+
winning_decision: The decision that prevailed.
128+
strategy_used: Which strategy resolved the conflict.
129+
candidates_evaluated: How many candidates were considered.
130+
conflict_detected: Whether genuinely conflicting decisions existed.
131+
resolution_trace: Human-readable trace of the resolution logic.
132+
"""
133+
134+
winning_decision: CandidateDecision
135+
strategy_used: ConflictResolutionStrategy
136+
candidates_evaluated: int = 0
137+
conflict_detected: bool = False
138+
resolution_trace: list[str] = Field(default_factory=list)
139+
140+
141+
class PolicyConflictResolver:
142+
"""Resolves conflicts between competing policy decisions.
143+
144+
Args:
145+
strategy: The conflict resolution strategy to apply.
146+
"""
147+
148+
def __init__(
149+
self,
150+
strategy: ConflictResolutionStrategy = ConflictResolutionStrategy.PRIORITY_FIRST_MATCH,
151+
) -> None:
152+
self.strategy = strategy
153+
154+
def resolve(self, candidates: list[CandidateDecision]) -> ResolutionResult:
155+
"""Resolve a list of candidate decisions into a single winner.
156+
157+
Args:
158+
candidates: One or more candidate decisions from matching rules.
159+
160+
Returns:
161+
A ``ResolutionResult`` containing the winning decision and
162+
a trace of the resolution logic.
163+
164+
Raises:
165+
ValueError: If ``candidates`` is empty.
166+
"""
167+
if not candidates:
168+
raise ValueError("Cannot resolve conflict with zero candidates")
169+
170+
if len(candidates) == 1:
171+
return ResolutionResult(
172+
winning_decision=candidates[0],
173+
strategy_used=self.strategy,
174+
candidates_evaluated=1,
175+
conflict_detected=False,
176+
resolution_trace=[f"Single candidate: {candidates[0].rule_name}{candidates[0].action}"],
177+
)
178+
179+
# Detect genuine conflict (mix of allow and deny)
180+
actions = {c.action for c in candidates}
181+
conflict_detected = "allow" in actions and "deny" in actions
182+
183+
dispatch = {
184+
ConflictResolutionStrategy.DENY_OVERRIDES: self._deny_overrides,
185+
ConflictResolutionStrategy.ALLOW_OVERRIDES: self._allow_overrides,
186+
ConflictResolutionStrategy.PRIORITY_FIRST_MATCH: self._priority_first_match,
187+
ConflictResolutionStrategy.MOST_SPECIFIC_WINS: self._most_specific_wins,
188+
}
189+
190+
winner, trace = dispatch[self.strategy](candidates)
191+
192+
return ResolutionResult(
193+
winning_decision=winner,
194+
strategy_used=self.strategy,
195+
candidates_evaluated=len(candidates),
196+
conflict_detected=conflict_detected,
197+
resolution_trace=trace,
198+
)
199+
200+
# ── Strategy implementations ────────────────────────────
201+
202+
def _deny_overrides(
203+
self, candidates: list[CandidateDecision]
204+
) -> tuple[CandidateDecision, list[str]]:
205+
"""DENY_OVERRIDES: any deny wins. Among denies, highest priority wins."""
206+
trace = []
207+
denies = [c for c in candidates if c.is_deny]
208+
if denies:
209+
denies.sort(key=lambda c: c.priority, reverse=True)
210+
winner = denies[0]
211+
trace.append(f"DENY_OVERRIDES: {len(denies)} deny rule(s) found")
212+
trace.append(f"Winner: {winner.rule_name} (priority={winner.priority}, scope={winner.scope.value})")
213+
return winner, trace
214+
215+
# No denies — pick highest-priority allow
216+
candidates_sorted = sorted(candidates, key=lambda c: c.priority, reverse=True)
217+
winner = candidates_sorted[0]
218+
trace.append("DENY_OVERRIDES: no deny rules, selecting highest-priority allow")
219+
trace.append(f"Winner: {winner.rule_name} (priority={winner.priority})")
220+
return winner, trace
221+
222+
def _allow_overrides(
223+
self, candidates: list[CandidateDecision]
224+
) -> tuple[CandidateDecision, list[str]]:
225+
"""ALLOW_OVERRIDES: any allow wins. Among allows, highest priority wins."""
226+
trace = []
227+
allows = [c for c in candidates if c.is_allow]
228+
if allows:
229+
allows.sort(key=lambda c: c.priority, reverse=True)
230+
winner = allows[0]
231+
trace.append(f"ALLOW_OVERRIDES: {len(allows)} allow rule(s) found")
232+
trace.append(f"Winner: {winner.rule_name} (priority={winner.priority}, scope={winner.scope.value})")
233+
return winner, trace
234+
235+
# No allows — pick highest-priority deny
236+
candidates_sorted = sorted(candidates, key=lambda c: c.priority, reverse=True)
237+
winner = candidates_sorted[0]
238+
trace.append("ALLOW_OVERRIDES: no allow rules, selecting highest-priority deny")
239+
trace.append(f"Winner: {winner.rule_name} (priority={winner.priority})")
240+
return winner, trace
241+
242+
def _priority_first_match(
243+
self, candidates: list[CandidateDecision]
244+
) -> tuple[CandidateDecision, list[str]]:
245+
"""PRIORITY_FIRST_MATCH: highest priority wins regardless of action."""
246+
sorted_candidates = sorted(candidates, key=lambda c: c.priority, reverse=True)
247+
winner = sorted_candidates[0]
248+
trace = [
249+
f"PRIORITY_FIRST_MATCH: {len(candidates)} candidates",
250+
f"Winner: {winner.rule_name} (priority={winner.priority}, action={winner.action})",
251+
]
252+
return winner, trace
253+
254+
def _most_specific_wins(
255+
self, candidates: list[CandidateDecision]
256+
) -> tuple[CandidateDecision, list[str]]:
257+
"""MOST_SPECIFIC_WINS: agent > tenant > global. Priority breaks ties."""
258+
sorted_candidates = sorted(
259+
candidates,
260+
key=lambda c: (c.specificity, c.priority),
261+
reverse=True,
262+
)
263+
winner = sorted_candidates[0]
264+
trace = [
265+
f"MOST_SPECIFIC_WINS: {len(candidates)} candidates",
266+
f"Specificity ranking: {[(c.rule_name, c.scope.value, c.specificity) for c in sorted_candidates]}",
267+
f"Winner: {winner.rule_name} (scope={winner.scope.value}, priority={winner.priority}, action={winner.action})",
268+
]
269+
return winner, trace

0 commit comments

Comments
 (0)