Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions alembic/versions/2026_04_09_0001-add_is_admin_to_users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""add is_admin column to users table

Revision ID: add_is_admin_to_users
Revises: add_communication_networks
Create Date: 2026-04-09 00:01:00.000000

"""
from alembic import op
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision = "add_is_admin_to_users"
down_revision = "add_communication_networks"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.add_column(
"users",
sa.Column("is_admin", sa.Boolean(), nullable=False, server_default="false"),
)


def downgrade() -> None:
op.drop_column("users", "is_admin")
36 changes: 36 additions & 0 deletions alembic/versions/2026_04_09_0002-add_halt_codes_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""add halt_codes table for distributed kill switch

Revision ID: add_halt_codes
Revises: add_is_admin_to_users
Create Date: 2026-04-09 00:02:00.000000

"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID

# revision identifiers, used by Alembic.
revision = "add_halt_codes"
down_revision = "add_is_admin_to_users"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.create_table(
"halt_codes",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("code_hash", sa.String(), nullable=False),
sa.Column("label", sa.String(), nullable=False),
sa.Column("trustee_name", sa.String(), nullable=False),
sa.Column("trustee_email", sa.String(), nullable=True),
sa.Column("is_master", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)


def downgrade() -> None:
op.drop_table("halt_codes")
20 changes: 20 additions & 0 deletions src/core/admin_auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Admin authentication dependency."""

from fastapi import Depends

from src.core.auth import get_current_user
from src.exceptions import ForbiddenException
from src.models.auth import User


async def get_admin_user(
current_user: User = Depends(get_current_user),
) -> User:
"""Require the current user to be an admin.

Wraps get_current_user and raises 403 if the user does not have
the is_admin flag set.
"""
if not getattr(current_user, "is_admin", False):
raise ForbiddenException("Admin access required")
return current_user
4 changes: 4 additions & 0 deletions src/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ class Settings(BaseSettings):
NETWORK_CALLBACK_TIMEOUT_SECONDS: int = 30
NETWORK_MESSAGE_DELIVERY_MAX_RETRIES: int = 3

# ── Safety & Governance ─────────────────────────────────────────────
SAFETY_CHECK_ENABLED: bool = True
AGENT_STATUS_CACHE_TTL: int = 300 # seconds to cache agent active status in Redis

# ── Economy settings (from agent-economy) ──────────────────────────
ECONOMY_WELCOME_BONUS_CREDITS: int = 500
ECONOMY_CREDIT_PACKAGES: list[dict] = [
Expand Down
17 changes: 17 additions & 0 deletions src/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
"ValidationException",
"DatabaseException",
"RateLimitException",
"PlatformHaltedException",
"AgentDisabledException",
]


Expand Down Expand Up @@ -91,3 +93,18 @@ class RateLimitException(BaseCustomException):

def __init__(self, detail: str = "Rate limit exceeded. Please try again later"):
super().__init__(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=detail)


# Safety & Governance Exceptions
class PlatformHaltedException(BaseCustomException):
"""Exception raised when the platform is in emergency halt mode"""

def __init__(self, detail: str = "Platform is in emergency halt mode. All agent operations are suspended."):
super().__init__(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=detail)


class AgentDisabledException(BaseCustomException):
"""Exception raised when a disabled agent is invoked"""

def __init__(self, detail: str = "Agent has been disabled by an administrator"):
super().__init__(status_code=status.HTTP_403_FORBIDDEN, detail=detail)
6 changes: 6 additions & 0 deletions src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
from src.routes.message import router as message_router
from src.routes.registry import router as registry_router
from src.routes.task import router as task_router
from src.routes.admin import router as admin_router
from src.routes.safety import router as safety_router
from src.mcp_app import create_mcp_app

# Workflow routers (from agent-os)
Expand Down Expand Up @@ -232,6 +234,10 @@ async def handle_workflow_exception(_request: Request, exc: WorkflowAppException
app.include_router(invocation_log_router)
app.include_router(task_router)

# ── Admin / Safety routers ───────────────────────────────────────────
app.include_router(admin_router, tags=["Admin"])
app.include_router(safety_router, tags=["Safety"])

# ── Workflow routers (from agent-os) ─────────────────────────────────
app.include_router(workflow_router, prefix="/workflows", tags=["Workflows"])
app.include_router(execution_router, tags=["Executions"])
Expand Down
2 changes: 2 additions & 0 deletions src/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from src.models.message import Message
from src.models.registry import Agent, AgentCredential, AgentRating
from src.models.task import Task
from src.models.halt_code import HaltCode

# Workflow models (from agent-os)
from src.workflow.models.entities import ( # noqa: F401
Expand Down Expand Up @@ -47,6 +48,7 @@
"AgentCredential",
"InvocationLog",
"Task",
"HaltCode",
# Workflow
"WorkflowDefinition",
"WorkflowExecution",
Expand Down
1 change: 1 addition & 0 deletions src/models/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class User(BaseModel):
last_name: Column[str] = Column(String, nullable=True)
phone_number: Column[str] = Column(String, nullable=True, unique=True)
is_active: Column[bool] = Column(Boolean, default=True, nullable=False)
is_admin: Column[bool] = Column(Boolean, default=False, nullable=False)

# Relationships
api_keys = relationship("ApiKey", back_populates="user", cascade="all, delete-orphan")
Expand Down
25 changes: 25 additions & 0 deletions src/models/halt_code.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Halt code model — distributed kill switch codes for trustees."""

from typing import Optional
from uuid import UUID

from sqlalchemy import Boolean, Column, ForeignKey, String
from sqlalchemy.dialects.postgresql import UUID as PostgresUUID

from .base import BaseModel


class HaltCode(BaseModel):
"""A halt code held by a trustee who can stop the platform."""

__tablename__: str = "halt_codes"

code_hash: Column[str] = Column(String, nullable=False)
label: Column[str] = Column(String, nullable=False)
trustee_name: Column[str] = Column(String, nullable=False)
trustee_email: Column[Optional[str]] = Column(String, nullable=True)
is_master: Column[bool] = Column(Boolean, default=False, nullable=False)
is_active: Column[bool] = Column(Boolean, default=True, nullable=False)
created_by: Column[UUID] = Column(
PostgresUUID, ForeignKey("users.id"), nullable=False
)
4 changes: 4 additions & 0 deletions src/network/a2a/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ async def a2a_task_send(
from src.network.utils.context_manager import NetworkContextManager
from src.database import get_redis

# Safety check: reject if platform is in emergency halt
from src.services.safety import check_platform_halt
await check_platform_halt()

params = data.params
task_data = params.get("task", {})
network_id = params.get("network_id")
Expand Down
14 changes: 14 additions & 0 deletions src/network/services/channels.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,10 @@ async def handle_callback(
This is the key to bidirectionality: external agents POST to their
reply_url and this method records the message in the network.
"""
# Safety check: reject if platform is in emergency halt
from src.services.safety import check_platform_halt
await check_platform_halt()

sender = await self.repo.get_participant(participant_id)
if not sender or sender.network_id != network_id:
raise NotFoundException("Participant")
Expand Down Expand Up @@ -322,6 +326,10 @@ async def _validate_communication(
sender_id: UUID,
recipient_id: UUID,
) -> tuple[NetworkParticipant, NetworkParticipant, CommunicationNetwork]:
# Safety check: reject if platform is in emergency halt
from src.services.safety import check_agent_active, check_platform_halt
await check_platform_halt()

network = await self.repo.get_network(network_id)
if not network:
raise NotFoundException("Network")
Expand All @@ -340,6 +348,12 @@ async def _validate_communication(
if recipient.status != ParticipantStatus.active:
raise BadRequestException("Recipient is not active")

# Safety check: verify linked agents are still active
if sender.agent_id:
await check_agent_active(sender.agent_id)
if recipient.agent_id:
await check_agent_active(recipient.agent_id)

return sender, recipient, network

async def _record_message(
Expand Down
Loading