|
| 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