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