Skip to content

Commit 092d329

Browse files
ClawRotClawRot
authored andcommitted
feat: APSPolicyEvaluator — ADK GovernancePlugin adapter
Maps sunilp's PolicyEvaluator protocol (google/adk-python-community#102) to APS 3-signature policy chain (intent → decision → receipt). - APSPolicyEvaluator: evaluate_tool_call + evaluate_agent_delegation - APSPolicyDecision: duck-typed compatible with ADK's PolicyDecision - Wildcard scope expansion (tool:* → specific tool) - Monotonic narrowing enforcement for delegation - Full cryptographic proof chain in metadata - 16 tests, 3 suites, 102 total passing 102 tests, 0 failures.
1 parent a787d40 commit 092d329

3 files changed

Lines changed: 532 additions & 0 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""APS integrations with external frameworks."""
2+
3+
from .adk_adapter import APSPolicyEvaluator, APSPolicyDecision, Decision
Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
"""APSPolicyEvaluator — Agent Passport System adapter for Google ADK GovernancePlugin.
2+
3+
Maps ADK's PolicyEvaluator protocol to APS's 3-signature policy chain:
4+
evaluate_tool_call() → create_action_intent() → evaluate_intent()
5+
evaluate_agent_delegation() → delegation scope narrowing check
6+
7+
Usage with sunilp's GovernancePlugin (google/adk-python-community#102):
8+
9+
from agent_passport.integrations.adk_adapter import APSPolicyEvaluator
10+
from google.adk_community.governance import GovernancePlugin
11+
12+
evaluator = APSPolicyEvaluator(
13+
agent_id="agent_alice",
14+
agent_public_key=alice_keys.public_key,
15+
agent_private_key=alice_keys.private_key,
16+
delegation_id="del_abc123",
17+
validation_context={
18+
"agentRegistered": True,
19+
"agentAttestationValid": True,
20+
"delegationValid": True,
21+
"delegationRevoked": False,
22+
"delegationExpired": False,
23+
"delegationScope": ["tool:*"],
24+
"floorVersion": "0.1",
25+
},
26+
)
27+
plugin = GovernancePlugin(policy_evaluator=evaluator)
28+
"""
29+
30+
from __future__ import annotations
31+
32+
import time
33+
from dataclasses import dataclass, field
34+
from enum import Enum
35+
from typing import Any, Dict, Optional
36+
37+
from ..policy import (
38+
create_action_intent,
39+
evaluate_intent,
40+
FloorValidatorV1,
41+
)
42+
from ..delegation import create_delegation, sub_delegate
43+
44+
45+
# ══════════════════════════════════════
46+
# Compatible PolicyDecision (duck-typed)
47+
# ══════════════════════════════════════
48+
# Structurally compatible with sunilp's PolicyDecision from
49+
# google.adk_community.governance.governance_plugin without importing it.
50+
51+
52+
class Decision(str, Enum):
53+
ALLOW = "ALLOW"
54+
DENY = "DENY"
55+
56+
57+
58+
@dataclass(frozen=True)
59+
class APSPolicyDecision:
60+
"""APS-flavored PolicyDecision, duck-type compatible with sunilp's schema.
61+
62+
ADK GovernancePlugin checks for .decision and .reason attributes.
63+
We add APS-specific fields in .metadata for cryptographic proof chain.
64+
"""
65+
decision: Decision
66+
reason: str = ""
67+
evaluator: str = "aps-floor-validator-v1"
68+
timestamp: float = field(default_factory=time.time)
69+
# APS extensions — the cryptographic proof chain
70+
metadata: Dict[str, Any] = field(default_factory=dict)
71+
72+
@staticmethod
73+
def allow(reason: str = "", metadata: Optional[Dict[str, Any]] = None) -> APSPolicyDecision:
74+
return APSPolicyDecision(
75+
decision=Decision.ALLOW, reason=reason,
76+
metadata=metadata or {},
77+
)
78+
79+
@staticmethod
80+
def deny(reason: str = "", metadata: Optional[Dict[str, Any]] = None) -> APSPolicyDecision:
81+
return APSPolicyDecision(
82+
decision=Decision.DENY, reason=reason,
83+
metadata=metadata or {},
84+
)
85+
86+
87+
# ══════════════════════════════════════
88+
# APS POLICY EVALUATOR
89+
# ══════════════════════════════════════
90+
91+
92+
# Verdict mapping: APS → ADK
93+
_VERDICT_MAP = {
94+
"permit": Decision.ALLOW,
95+
"narrow": Decision.ALLOW, # narrow = allowed with constraints
96+
"deny": Decision.DENY,
97+
}
98+
99+
100+
class APSPolicyEvaluator:
101+
"""PolicyEvaluator implementation backed by APS 3-signature policy chain.
102+
103+
Implements sunilp's PolicyEvaluator protocol (evaluate_tool_call,
104+
evaluate_agent_delegation) using APS's FloorValidatorV1.
105+
106+
Every evaluation produces a signed PolicyDecision with the full
107+
cryptographic proof chain in metadata. The ADK GovernancePlugin
108+
sees a standard ALLOW/DENY. Anyone who inspects metadata gets
109+
the APS signature, principle evaluations, and delegation context.
110+
"""
111+
112+
def __init__(
113+
self,
114+
agent_id: str,
115+
agent_public_key: str,
116+
agent_private_key: str,
117+
delegation_id: str,
118+
validation_context: Dict[str, Any],
119+
evaluator_id: Optional[str] = None,
120+
evaluator_public_key: Optional[str] = None,
121+
evaluator_private_key: Optional[str] = None,
122+
validator: Optional[FloorValidatorV1] = None,
123+
):
124+
self.agent_id = agent_id
125+
self.agent_public_key = agent_public_key
126+
self.agent_private_key = agent_private_key
127+
self.delegation_id = delegation_id
128+
self.validation_context = validation_context
129+
# Evaluator defaults to agent (self-evaluation) if not provided
130+
self.evaluator_id = evaluator_id or agent_id
131+
self.evaluator_public_key = evaluator_public_key or agent_public_key
132+
self.evaluator_private_key = evaluator_private_key or agent_private_key
133+
self.validator = validator or FloorValidatorV1()
134+
135+
def _build_internal_context(self, scope_required: str) -> Dict[str, Any]:
136+
"""Translate user-friendly context to FloorValidatorV1 format.
137+
138+
FloorValidatorV1 reads scope from ctx["delegation"]["scope"].
139+
The adapter accepts a flat delegationScope list for convenience.
140+
Wildcards (tool:*) are expanded to include the specific scope.
141+
"""
142+
ctx = dict(self.validation_context)
143+
raw_scope = ctx.pop("delegationScope", [])
144+
145+
# Expand wildcards: "tool:*" means all tools are allowed
146+
resolved_scope = list(raw_scope)
147+
if "tool:*" in resolved_scope and scope_required not in resolved_scope:
148+
resolved_scope.append(scope_required)
149+
150+
# Build delegation object in the format FloorValidatorV1 expects
151+
delegation = ctx.get("delegation", {})
152+
if not isinstance(delegation, dict):
153+
delegation = {}
154+
delegation["scope"] = resolved_scope
155+
delegation["delegationId"] = self.delegation_id
156+
157+
# Map flat fields to delegation sub-fields
158+
if ctx.get("delegationRevoked"):
159+
delegation["revoked"] = True
160+
if ctx.get("delegationExpired"):
161+
delegation["expiresAt"] = "2020-01-01T00:00:00Z"
162+
delegation["currentDepth"] = ctx.get("currentDelegationDepth", 0)
163+
delegation["maxDepth"] = ctx.get("maxDelegationDepth", 10)
164+
165+
ctx["delegation"] = delegation
166+
return ctx
167+
168+
async def evaluate_tool_call(
169+
self,
170+
*,
171+
tool_name: str,
172+
tool_args: Dict[str, Any],
173+
agent_name: str,
174+
context: Optional[Any] = None,
175+
) -> APSPolicyDecision:
176+
"""Evaluate a tool call through APS 3-signature policy chain.
177+
178+
1. Creates an ActionIntent (signature 1)
179+
2. Evaluates via FloorValidatorV1 (signature 2)
180+
3. Returns decision with proof chain in metadata
181+
"""
182+
scope_required = f"tool:{tool_name}"
183+
184+
# Build internal context: translate user-friendly format to what
185+
# FloorValidatorV1 expects (delegation.scope, not delegationScope)
186+
internal_ctx = self._build_internal_context(scope_required)
187+
188+
# Step 1: Create APS ActionIntent
189+
action = {
190+
"toolName": tool_name,
191+
"toolArgs": tool_args,
192+
"scopeRequired": scope_required,
193+
"agentName": agent_name,
194+
}
195+
try:
196+
intent = create_action_intent(
197+
agent_id=self.agent_id,
198+
agent_public_key=self.agent_public_key,
199+
delegation_id=self.delegation_id,
200+
action=action,
201+
private_key=self.agent_private_key,
202+
)
203+
except Exception as e:
204+
return APSPolicyDecision.deny(
205+
reason=f"Failed to create intent: {e}",
206+
metadata={"error": str(e), "stage": "intent_creation"},
207+
)
208+
209+
# Step 2: Evaluate intent through FloorValidatorV1
210+
try:
211+
decision = evaluate_intent(
212+
intent=intent,
213+
validator=self.validator,
214+
validation_context=internal_ctx,
215+
evaluator_id=self.evaluator_id,
216+
evaluator_public_key=self.evaluator_public_key,
217+
evaluator_private_key=self.evaluator_private_key,
218+
)
219+
except Exception as e:
220+
return APSPolicyDecision.deny(
221+
reason=f"Policy evaluation failed: {e}",
222+
metadata={"error": str(e), "stage": "evaluation"},
223+
)
224+
225+
# Step 3: Map APS verdict to ADK decision
226+
verdict = decision.get("verdict", "deny")
227+
adk_decision = _VERDICT_MAP.get(verdict, Decision.DENY)
228+
229+
# Build proof metadata
230+
metadata = {
231+
"aps_intent_id": intent.get("intentId"),
232+
"aps_decision_id": decision.get("decisionId"),
233+
"aps_verdict": verdict,
234+
"aps_signature": decision.get("signature"),
235+
"aps_evaluator_id": decision.get("evaluatorId"),
236+
"aps_principles_evaluated": decision.get("principlesEvaluated"),
237+
"aps_constraints": decision.get("constraints"),
238+
"aps_floor_version": decision.get("floorVersion"),
239+
"aps_expires_at": decision.get("expiresAt"),
240+
"aps_audit_findings": decision.get("auditFindings"),
241+
"aps_warnings": decision.get("warnings"),
242+
}
243+
244+
return APSPolicyDecision(
245+
decision=adk_decision,
246+
reason=decision.get("reason", ""),
247+
evaluator="aps-floor-validator-v1",
248+
metadata=metadata,
249+
)
250+
251+
async def evaluate_agent_delegation(
252+
self,
253+
*,
254+
parent_agent_name: str,
255+
child_agent_name: str,
256+
delegation_scope: Optional[Any] = None,
257+
context: Optional[Any] = None,
258+
) -> APSPolicyDecision:
259+
"""Evaluate whether agent delegation is permitted.
260+
261+
Checks monotonic narrowing: child's requested tools must be
262+
a subset of parent's delegation scope.
263+
"""
264+
parent_scope = self.validation_context.get("delegationScope", [])
265+
266+
# If no delegation scope provided by ADK, allow (no constraints)
267+
if delegation_scope is None:
268+
return APSPolicyDecision.allow(
269+
reason="No delegation constraints specified",
270+
metadata={"parent_scope": parent_scope},
271+
)
272+
273+
# Extract child's requested tools from ADK DelegationScope
274+
child_tools = set()
275+
if hasattr(delegation_scope, "allowed_tools"):
276+
child_tools = delegation_scope.allowed_tools
277+
elif isinstance(delegation_scope, dict):
278+
child_tools = set(delegation_scope.get("allowed_tools", []))
279+
280+
# Monotonic narrowing: child tools must be subset of parent scope
281+
# Parent scope "tool:*" means all tools allowed
282+
has_wildcard = "tool:*" in parent_scope
283+
if not has_wildcard and child_tools:
284+
parent_tool_set = {
285+
s.replace("tool:", "") for s in parent_scope
286+
if s.startswith("tool:")
287+
}
288+
violations = child_tools - parent_tool_set
289+
if violations:
290+
return APSPolicyDecision.deny(
291+
reason=f"Delegation scope violation: {violations} not in parent scope",
292+
metadata={
293+
"parent_scope": parent_scope,
294+
"child_requested": list(child_tools),
295+
"violations": list(violations),
296+
},
297+
)
298+
299+
# Check max delegation depth
300+
max_depth = 0
301+
if hasattr(delegation_scope, "max_delegation_depth"):
302+
max_depth = delegation_scope.max_delegation_depth
303+
elif isinstance(delegation_scope, dict):
304+
max_depth = delegation_scope.get("max_delegation_depth", 0)
305+
306+
ctx_depth = self.validation_context.get("currentDelegationDepth", 0)
307+
if max_depth > 0 and ctx_depth >= max_depth:
308+
return APSPolicyDecision.deny(
309+
reason=f"Max delegation depth exceeded ({ctx_depth}/{max_depth})",
310+
metadata={"current_depth": ctx_depth, "max_depth": max_depth},
311+
)
312+
313+
return APSPolicyDecision.allow(
314+
reason="Delegation within scope",
315+
metadata={
316+
"parent_scope": parent_scope,
317+
"child_tools": list(child_tools) if child_tools else [],
318+
"narrowing": "valid",
319+
},
320+
)

0 commit comments

Comments
 (0)