Skip to content

Commit b8efcb9

Browse files
Merge pull request #11 from lleonardo-franco/feature/policy-federation
Add cross-agent policy federation
2 parents 9c42106 + f453c55 commit b8efcb9

5 files changed

Lines changed: 544 additions & 3 deletions

File tree

src/tealtiger/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@
6666
ModeConfig,
6767
Decision,
6868
)
69+
from tealtiger.core.engine.policy_federation import (
70+
PolicyFederation,
71+
PolicyFederationVerificationResult,
72+
)
6973
from tealtiger.core.context import (
7074
ExecutionContext,
7175
ExecutionContextOptions,
@@ -149,4 +153,6 @@
149153
"PolicyTestSuite",
150154
"PolicyTestResult",
151155
"PolicyTestReport",
156+
"PolicyFederation",
157+
"PolicyFederationVerificationResult",
152158
]

src/tealtiger/core/engine/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
"""Engine components for TealTiger SDK."""
22

33
from tealtiger.core.engine.teal_engine import TealEngine
4+
from tealtiger.core.engine.policy_federation import (
5+
PolicyFederation,
6+
PolicyFederationVerificationResult,
7+
)
48
from tealtiger.core.engine.types import (
59
Decision,
610
DecisionAction,
@@ -16,4 +20,6 @@
1620
"DecisionAction",
1721
"ReasonCode",
1822
"Decision",
23+
"PolicyFederation",
24+
"PolicyFederationVerificationResult",
1925
]
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
"""Cross-agent policy federation protocol.
2+
3+
This module provides signed, transport-agnostic policy tokens and deterministic
4+
most-restrictive-wins policy merging for parent/child agent systems.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import base64
10+
import copy
11+
import hashlib
12+
import hmac
13+
import json
14+
import time
15+
from dataclasses import dataclass
16+
from typing import Any, Dict, List, Optional
17+
18+
from tealtiger.core.context import ContextManager, ExecutionContext, ExecutionContextOptions
19+
20+
TOKEN_PREFIX = "ttfp.v1"
21+
CLASSIFICATION_RANKS = {
22+
"public": 0,
23+
"internal": 1,
24+
"confidential": 2,
25+
"restricted": 3,
26+
}
27+
28+
29+
@dataclass(frozen=True)
30+
class PolicyFederationVerificationResult:
31+
"""Result returned after policy token verification."""
32+
33+
valid: bool
34+
payload: Optional[Dict[str, Any]] = None
35+
error: Optional[str] = None
36+
37+
38+
class PolicyFederation:
39+
"""Policy federation helpers shared by parent and child agents."""
40+
41+
@staticmethod
42+
def create_token(payload: Dict[str, Any], secret: str) -> str:
43+
"""Create a signed policy federation token."""
44+
45+
encoded_payload = _base64_url_encode(_stable_json(payload).encode("utf-8"))
46+
signing_input = f"{TOKEN_PREFIX}.{encoded_payload}"
47+
signature = _sign(signing_input, secret)
48+
return f"{signing_input}.{signature}"
49+
50+
@staticmethod
51+
def verify_token(
52+
token: str,
53+
secret: str,
54+
now_ms: Optional[int] = None,
55+
) -> PolicyFederationVerificationResult:
56+
"""Verify a policy federation token and decode its payload."""
57+
58+
parts = token.split(".")
59+
if len(parts) != 4 or f"{parts[0]}.{parts[1]}" != TOKEN_PREFIX:
60+
return PolicyFederationVerificationResult(
61+
valid=False,
62+
error="Invalid policy federation token format",
63+
)
64+
65+
signing_input = f"{parts[0]}.{parts[1]}.{parts[2]}"
66+
expected_signature = _sign(signing_input, secret)
67+
if not hmac.compare_digest(parts[3], expected_signature):
68+
return PolicyFederationVerificationResult(
69+
valid=False,
70+
error="Invalid policy federation token signature",
71+
)
72+
73+
try:
74+
payload = json.loads(_base64_url_decode(parts[2]).decode("utf-8"))
75+
except (json.JSONDecodeError, ValueError):
76+
return PolicyFederationVerificationResult(
77+
valid=False,
78+
error="Invalid policy federation token payload",
79+
)
80+
81+
current_time = int(time.time() * 1000) if now_ms is None else now_ms
82+
expires_at = payload.get("expiresAt")
83+
if expires_at is not None and int(expires_at) <= current_time:
84+
return PolicyFederationVerificationResult(
85+
valid=False,
86+
error="Policy federation token expired",
87+
)
88+
89+
return PolicyFederationVerificationResult(valid=True, payload=payload)
90+
91+
@staticmethod
92+
def decode_token(token: str) -> Dict[str, Any]:
93+
"""Decode a token without verifying its signature."""
94+
95+
parts = token.split(".")
96+
if len(parts) != 4 or f"{parts[0]}.{parts[1]}" != TOKEN_PREFIX:
97+
raise ValueError("Invalid policy federation token format")
98+
99+
return json.loads(_base64_url_decode(parts[2]).decode("utf-8"))
100+
101+
@staticmethod
102+
def extract_constraints(federation: Dict[str, Any]) -> Dict[str, Any]:
103+
"""Return constraints from either a payload or raw constraints dict."""
104+
105+
constraints = federation.get("constraints")
106+
return dict(constraints) if isinstance(constraints, dict) else dict(federation)
107+
108+
@staticmethod
109+
def merge_policies(
110+
child_policy: Dict[str, Any],
111+
federation: Dict[str, Any],
112+
) -> Dict[str, Any]:
113+
"""Merge parent constraints into child policy using most-restrictive-wins."""
114+
115+
constraints = PolicyFederation.extract_constraints(federation)
116+
merged = copy.deepcopy(child_policy)
117+
118+
if constraints.get("toolAllowlist") is not None or constraints.get("revokedTools"):
119+
merged["tools"] = _merge_tool_policies(
120+
merged.get("tools"),
121+
constraints.get("toolAllowlist"),
122+
constraints.get("revokedTools"),
123+
)
124+
125+
if constraints.get("budget"):
126+
behavioral = dict(merged.get("behavioral") or {})
127+
behavioral["costLimit"] = _merge_cost_limit(
128+
behavioral.get("costLimit"),
129+
constraints["budget"],
130+
)
131+
behavioral.setdefault("rateLimit", {"requests": 9007199254740991, "window": "1d"})
132+
merged["behavioral"] = behavioral
133+
134+
identity = merged.get("identity")
135+
if isinstance(identity, dict):
136+
identity = dict(identity)
137+
identity["costLimit"] = _merge_cost_limit(
138+
identity.get("costLimit"),
139+
constraints["budget"],
140+
)
141+
merged["identity"] = identity
142+
143+
data_classification = constraints.get("dataClassification")
144+
if data_classification:
145+
content = dict(merged.get("content") or {})
146+
current = (content.get("dataClassification") or {}).get("maxLevel")
147+
max_level = (
148+
_most_restrictive_classification(current, data_classification)
149+
if current
150+
else data_classification
151+
)
152+
content["dataClassification"] = {"maxLevel": max_level}
153+
merged["content"] = content
154+
155+
return merged
156+
157+
@staticmethod
158+
def apply_revocation(
159+
constraints: Dict[str, Any],
160+
revoked_tools: List[str],
161+
) -> Dict[str, Any]:
162+
"""Return constraints with async parent tool revocations applied."""
163+
164+
next_constraints = dict(constraints)
165+
revoked = set(next_constraints.get("revokedTools") or [])
166+
revoked.update(revoked_tools)
167+
next_constraints["revokedTools"] = sorted(revoked)
168+
169+
allowlist = next_constraints.get("toolAllowlist")
170+
if allowlist is not None:
171+
next_constraints["toolAllowlist"] = [
172+
tool for tool in allowlist if tool not in revoked
173+
]
174+
175+
return next_constraints
176+
177+
@staticmethod
178+
def create_child_context(
179+
payload: Dict[str, Any],
180+
child_correlation_id: Optional[str] = None,
181+
trace_id: Optional[str] = None,
182+
span_id: Optional[str] = None,
183+
) -> ExecutionContext:
184+
"""Create a child execution context linked to the parent trace."""
185+
186+
trace_chain = list(payload.get("traceChain") or [])
187+
parent_correlation_id = payload.get("parentCorrelationId")
188+
if parent_correlation_id:
189+
trace_chain.append(parent_correlation_id)
190+
191+
options = ExecutionContextOptions(
192+
correlation_id=child_correlation_id,
193+
trace_id=trace_id,
194+
span_id=span_id,
195+
metadata={
196+
"federation_issuer": payload.get("issuer"),
197+
"federation_revision": payload.get("revision"),
198+
"parent_correlation_id": parent_correlation_id,
199+
"trace_chain": trace_chain,
200+
},
201+
)
202+
return ContextManager.create_context(options)
203+
204+
205+
def _merge_tool_policies(
206+
child_tools: Optional[Dict[str, Any]],
207+
allowlist: Optional[List[str]],
208+
revoked_tools: Optional[List[str]],
209+
) -> Dict[str, Any]:
210+
result = copy.deepcopy(child_tools or {})
211+
revoked = set(revoked_tools or [])
212+
213+
if allowlist is not None:
214+
allowed = set(allowlist)
215+
result["*"] = {"allowed": False}
216+
217+
for tool in allowlist:
218+
child = dict(result.get(tool) or {})
219+
child["allowed"] = bool(child.get("allowed", True)) and tool not in revoked
220+
result[tool] = child
221+
222+
for tool, config in list(result.items()):
223+
if tool != "*" and tool not in allowed:
224+
next_config = dict(config)
225+
next_config["allowed"] = False
226+
result[tool] = next_config
227+
elif not child_tools and revoked:
228+
result["*"] = {"allowed": True}
229+
230+
for tool in revoked:
231+
next_config = dict(result.get(tool) or {})
232+
next_config["allowed"] = False
233+
result[tool] = next_config
234+
235+
return result
236+
237+
238+
def _merge_cost_limit(
239+
current: Optional[Dict[str, Any]],
240+
budget: Dict[str, Any],
241+
) -> Dict[str, Any]:
242+
result = dict(current or {})
243+
daily_ceiling = _min_defined(budget.get("daily"), budget.get("remaining"))
244+
245+
daily = _min_defined(result.get("daily"), daily_ceiling)
246+
if daily is not None:
247+
result["daily"] = daily
248+
249+
hourly = _min_defined(result.get("hourly"), budget.get("hourly"))
250+
if hourly is not None:
251+
result["hourly"] = hourly
252+
253+
monthly = _min_defined(result.get("monthly"), budget.get("monthly"))
254+
if monthly is not None:
255+
result["monthly"] = monthly
256+
257+
return result
258+
259+
260+
def _min_defined(left: Optional[float], right: Optional[float]) -> Optional[float]:
261+
if left is None:
262+
return right
263+
if right is None:
264+
return left
265+
return min(left, right)
266+
267+
268+
def _most_restrictive_classification(left: str, right: str) -> str:
269+
return left if CLASSIFICATION_RANKS[left] <= CLASSIFICATION_RANKS[right] else right
270+
271+
272+
def _sign(signing_input: str, secret: str) -> str:
273+
digest = hmac.new(
274+
secret.encode("utf-8"),
275+
signing_input.encode("utf-8"),
276+
hashlib.sha256,
277+
).digest()
278+
return _base64_url_encode(digest)
279+
280+
281+
def _stable_json(value: Dict[str, Any]) -> str:
282+
return json.dumps(value, sort_keys=True, separators=(",", ":"))
283+
284+
285+
def _base64_url_encode(value: bytes) -> str:
286+
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
287+
288+
289+
def _base64_url_decode(value: str) -> bytes:
290+
padding = "=" * ((4 - len(value) % 4) % 4)
291+
return base64.urlsafe_b64decode(f"{value}{padding}")

0 commit comments

Comments
 (0)