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:
Tyler King 2026-04-04 16:28:37 -04:00
commit 944b3fde19
13 changed files with 898 additions and 0 deletions

21
.env.example Normal file
View 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
View file

@ -0,0 +1,5 @@
.venv/
__pycache__/
*.pyc
*.db
.env

0
llm_broker/__init__.py Normal file
View file

65
llm_broker/chronicle.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file