This repository has been archived on 2026-04-16. You can view files and clone it, but cannot push or open issues or pull requests.
llm-principal-broker/llm_broker/delegation.py
Tyler King 944b3fde19 feat: LLM Principal Broker MVP
FastAPI companion service to the GSAP broker for AI agent
identity delegation in governed shell sessions.

Implements GCAP-SPEC-LLM-PRINCIPAL-BROKER-0001:
  POST /delegate — request delegation (human → AI agent)
  POST /delegate/{id}/revoke — revoke delegation
  GET  /delegate/{id} — delegation status
  GET  /agents — list active delegations
  GET  /health — health check

Delegation lifecycle:
  REQUESTED → ACTIVE → EXPIRED | REVOKED
  Cascading revocation on delegator AC revocation
  Background cleanup of expired delegations (30s interval)

Keycloak integration:
  Registers ephemeral agent clients per delegation
  Deletes clients on revocation/expiry
  Dev mode: stubs when no client_secret configured

GSAP broker integration:
  Requests delegated ACs via on_behalf_of pattern
  Scope narrowing: agent ceiling ≤ delegator ceiling
  Dev mode: stubs when no bearer_token configured

Chronicle integration:
  DELEGATION_CREATED (0x3001)
  DELEGATION_REVOKED (0x3003)
  DELEGATION_EXPIRED (0x3004)

All 7 smoke tests pass (health, create, list, query, revoke, verify, empty).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 16:28:37 -04:00

139 lines
5.1 KiB
Python

"""Core delegation lifecycle — GCAP-SPEC-LLM-PRINCIPAL-BROKER-0001 §3."""
import logging
import uuid
from datetime import datetime, timedelta
from llm_broker import chronicle, db
from llm_broker.db import DelegationDB
from llm_broker.gsap import GSAPClient
from llm_broker.keycloak import KeycloakAdmin
from llm_broker.models import (
AgentPrincipal,
DelegationRequest,
DelegationResponse,
DelegationScope,
)
from llm_broker.settings import Settings
logger = logging.getLogger(__name__)
class DelegationManager:
def __init__(self, config: Settings):
self.config = config
self.keycloak = KeycloakAdmin(
config.keycloak_url,
config.keycloak_realm,
config.keycloak_admin_client_id,
config.keycloak_admin_client_secret,
)
self.gsap = GSAPClient(config.gsap_broker_url, config.gsap_bearer_token)
async def create_delegation(
self, request: DelegationRequest, delegator_did: str
) -> DelegationResponse:
delegation_id = f"del-{uuid.uuid4().hex[:8]}"
scope = request.scope or DelegationScope()
now = datetime.utcnow()
expires_at = now + timedelta(minutes=scope.max_ttl_minutes)
agent_did = f"did:web:guildhouse.dev/agent/{request.agent_type}-{delegation_id}"
agent_client_id = f"agent-{request.agent_type}-{delegation_id}"
delegator_short = delegator_did.rsplit("/", 1)[-1]
agent_display = f"{request.agent_type} (delegated by {delegator_short})"
# 1. Register agent in Keycloak
kc_result = await self.keycloak.register_agent_client(
client_id=agent_client_id,
display_name=agent_display,
delegator_did=delegator_did,
delegation_id=delegation_id,
agent_type=request.agent_type,
)
# 2. Request delegated AC from GSAP broker
try:
ac_result = await self.gsap.request_delegated_ac(
delegator_ac_id=request.delegator_ac_id,
agent_did=agent_did,
delegation_id=delegation_id,
corpus_entry_cid="sha256:dev-jumphost",
capability_ceiling=scope.capability_ceiling,
ttl_minutes=scope.max_ttl_minutes,
)
except Exception as e:
await self.keycloak.delete_agent_client(agent_client_id)
raise RuntimeError(f"Failed to request delegated AC: {e}")
# 3. Record in Chronicle
chronicle_cid = await chronicle.delegation_created(
delegation_id=delegation_id,
delegator_did=delegator_did,
agent_did=agent_did,
agent_type=request.agent_type,
scope=scope.model_dump(),
)
# 4. Persist
delegation = DelegationDB(
delegation_id=delegation_id,
status="active",
agent_type=request.agent_type,
agent_model=request.agent_model,
agent_did=agent_did,
agent_keycloak_client_id=agent_client_id,
delegator_did=delegator_did,
delegator_ac_id=request.delegator_ac_id,
delegated_ac_id=ac_result.get("context_id", ""),
capability_ceiling=scope.capability_ceiling,
ceremony_required_for=",".join(scope.ceremony_required_for),
max_commands=scope.max_commands,
created_at=now,
expires_at=expires_at,
chronicle_cid=chronicle_cid or None,
)
await db.create_delegation(delegation)
logger.info(
"Delegation created: %s (%s%s)", delegation_id, delegator_did, agent_did
)
return DelegationResponse(
delegation_id=delegation_id,
agent_principal=AgentPrincipal(
did=agent_did,
keycloak_client_id=agent_client_id,
display_name=agent_display,
),
delegated_ac=ac_result,
agent_token=kc_result.get("client_secret", ""),
expires_at=expires_at.isoformat(),
max_commands=scope.max_commands,
chronicle_cid=chronicle_cid,
)
async def revoke_delegation(self, delegation_id: str, reason: str) -> bool:
delegation = await db.get_delegation(delegation_id)
if not delegation or delegation.status != "active":
return False
if delegation.agent_keycloak_client_id:
await self.keycloak.delete_agent_client(delegation.agent_keycloak_client_id)
await db.revoke_delegation(delegation_id, reason)
await chronicle.delegation_revoked(delegation_id, reason)
logger.info("Delegation revoked: %s (%s)", delegation_id, reason)
return True
async def cleanup_expired(self) -> int:
"""Expire stale delegations and clean up Keycloak clients."""
expired = await db.expire_stale()
for d in expired:
if d.agent_keycloak_client_id:
await self.keycloak.delete_agent_client(d.agent_keycloak_client_id)
await chronicle.delegation_expired(d.delegation_id)
if expired:
logger.info("Expired %d stale delegations", len(expired))
return len(expired)