feat(agent-runner): enforce typed host permissions

This commit is contained in:
huanghuoguoguo
2026-06-10 22:36:23 +08:00
parent 8938ef7412
commit ea96d37e60
41 changed files with 584 additions and 3862 deletions

View File

@@ -26,49 +26,26 @@ def iter_schema_items(
yield item
def has_permission(
descriptor: AgentRunnerDescriptor | None,
name: str,
actions: set[str],
) -> bool:
"""Return whether a runner descriptor requests one of the given actions."""
if descriptor is None:
return False
configured_actions = descriptor.permissions.get(name, [])
return any(action in configured_actions for action in actions)
def uses_host_models(descriptor: AgentRunnerDescriptor | None) -> bool:
"""Return whether LangBot should resolve model resources for this runner."""
return (
has_permission(descriptor, 'models', {'invoke', 'stream', 'list'})
and any(True for _ in iter_schema_items(descriptor, LLM_MODEL_SELECTOR_TYPES))
)
return any(True for _ in iter_schema_items(descriptor, LLM_MODEL_SELECTOR_TYPES))
def uses_host_tools(descriptor: AgentRunnerDescriptor | None) -> bool:
"""Return whether LangBot should expose tool resources to this runner."""
return (
descriptor is not None
and descriptor.supports_tool_calling()
and has_permission(descriptor, 'tools', {'list', 'detail', 'call'})
)
return descriptor is not None and descriptor.supports_tool_calling()
def uses_host_knowledge_bases(descriptor: AgentRunnerDescriptor | None) -> bool:
"""Return whether LangBot should expose knowledge-base resources to this runner."""
return (
descriptor is not None
and descriptor.supports_knowledge_retrieval()
and has_permission(descriptor, 'knowledge_bases', {'list', 'retrieve'})
)
return descriptor is not None and descriptor.supports_knowledge_retrieval()
def supports_skill_authoring(descriptor: AgentRunnerDescriptor | None) -> bool:
"""Return whether the runner wants Host skill-authoring tools."""
if descriptor is None:
return False
return bool(descriptor.capabilities.get('skill_authoring', False))
return descriptor.capabilities.skill_authoring
def extract_prompt_config(

View File

@@ -44,7 +44,6 @@ class AgentInput(typing.TypedDict):
text: str | None
contents: list[dict[str, typing.Any]]
message_chain: dict[str, typing.Any] | None
attachments: list[dict[str, typing.Any]]
@@ -254,7 +253,6 @@ class AgentRunContextBuilder:
input: AgentInput = {
'text': event.input.text,
'contents': [c.model_dump(mode='json') if hasattr(c, 'model_dump') else c for c in event.input.contents],
'message_chain': event.input.message_chain,
'attachments': [
a.model_dump(mode='json') if hasattr(a, 'model_dump') else a for a in event.input.attachments
],
@@ -361,28 +359,33 @@ class AgentRunContextBuilder:
ContextAccess dict
"""
conversation_id = event.conversation_id
permissions = descriptor.permissions
history_perms = set(permissions.history)
event_perms = set(permissions.events)
artifact_perms = set(permissions.artifacts)
storage_perms = set(permissions.storage)
# Check if history APIs are available for this runner
# Based on runner permissions
permissions = descriptor.permissions or {}
history_permissions = permissions.get('history', [])
event_permissions = permissions.get('events', [])
artifact_permissions = permissions.get('artifacts', [])
history_page_enabled = 'page' in history_permissions and conversation_id is not None
history_search_enabled = 'search' in history_permissions and conversation_id is not None
event_get_enabled = 'get' in event_permissions
event_page_enabled = 'page' in event_permissions and conversation_id is not None
artifact_metadata_enabled = 'metadata' in artifact_permissions
artifact_read_enabled = 'read' in artifact_permissions
history_page_enabled = 'page' in history_perms and conversation_id is not None
history_search_enabled = 'search' in history_perms and conversation_id is not None
event_get_enabled = 'get' in event_perms
event_page_enabled = 'page' in event_perms and conversation_id is not None
artifact_metadata_enabled = 'metadata' in artifact_perms
artifact_read_enabled = 'read' in artifact_perms
# Determine state API availability based on binding state_policy.
state_enabled = False
storage_enabled = False
if binding is not None:
state_policy = binding.state_policy
if state_policy.enable_state and state_policy.state_scopes:
state_enabled = True
resource_policy = binding.resource_policy
storage_enabled = (
('plugin' in storage_perms and resource_policy.allow_plugin_storage)
or ('workspace' in storage_perms and resource_policy.allow_workspace_storage)
)
# Get latest cursor and has_history_before if conversation exists
latest_cursor = None
has_history_before = False
@@ -411,7 +414,7 @@ class AgentRunContextBuilder:
'delivered_count': 0,
'source_total_count': None,
'messages_complete': False,
'reason': 'self_managed_context',
'reason': 'current_event_only',
},
'available_apis': {
'history_page': history_page_enabled,
@@ -421,6 +424,6 @@ class AgentRunContextBuilder:
'artifact_metadata': artifact_metadata_enabled,
'artifact_read': artifact_read_enabled,
'state': state_enabled,
'storage': True,
'storage': storage_enabled,
},
}

View File

@@ -4,6 +4,11 @@ from __future__ import annotations
import typing
import pydantic
from langbot_plugin.api.entities.builtin.agent_runner.manifest import (
AgentRunnerCapabilities,
AgentRunnerPermissions,
)
class AgentRunnerDescriptor(pydantic.BaseModel):
"""Descriptor for an agent runner.
@@ -36,16 +41,20 @@ class AgentRunnerDescriptor(pydantic.BaseModel):
plugin_version: str | None = None
"""Optional plugin version"""
config_schema: list[dict[str, typing.Any]] = []
config_schema: list[dict[str, typing.Any]] = pydantic.Field(default_factory=list)
"""Configuration schema using DynamicForm format"""
capabilities: dict[str, bool] = {}
capabilities: AgentRunnerCapabilities = pydantic.Field(
default_factory=AgentRunnerCapabilities
)
"""Runner capabilities: streaming, tool_calling, knowledge_retrieval, etc."""
permissions: dict[str, list[str]] = {}
"""Requested permissions: models, tools, knowledge_bases, storage, files, platform_api"""
permissions: AgentRunnerPermissions = pydantic.Field(
default_factory=AgentRunnerPermissions
)
"""Requested LangBot resource permissions."""
raw_manifest: dict[str, typing.Any] = {}
raw_manifest: dict[str, typing.Any] = pydantic.Field(default_factory=dict)
"""Original manifest for reference"""
model_config = pydantic.ConfigDict(
@@ -58,12 +67,12 @@ class AgentRunnerDescriptor(pydantic.BaseModel):
def supports_streaming(self) -> bool:
"""Check if runner supports streaming output."""
return self.capabilities.get('streaming', False)
return self.capabilities.streaming
def supports_tool_calling(self) -> bool:
"""Check if runner supports tool calling."""
return self.capabilities.get('tool_calling', False)
return self.capabilities.tool_calling
def supports_knowledge_retrieval(self) -> bool:
"""Check if runner supports knowledge retrieval."""
return self.capabilities.get('knowledge_retrieval', False)
return self.capabilities.knowledge_retrieval

View File

@@ -106,7 +106,7 @@ class AgentRunOrchestrator:
query_id=session_query_id,
plugin_identity=descriptor.get_plugin_id(),
resources=resources,
permissions=descriptor.permissions or {},
available_apis=context.get('context', {}).get('available_apis'),
conversation_id=event.conversation_id,
state_policy={
'enable_state': binding.state_policy.enable_state,
@@ -137,6 +137,12 @@ class AgentRunOrchestrator:
try:
async for result_dict in self.invoker.invoke(descriptor, context):
result_type = result_dict.get('type')
if result_type and not self.result_normalizer.validate_payload(
result_type,
result_dict.get('data', {}),
descriptor,
):
continue
if result_type == 'artifact.created':
artifact_ref = await self.journal.handle_artifact_created(

View File

@@ -396,18 +396,11 @@ class QueryEntryAdapter:
if text_parts:
text = ''.join(text_parts)
message_chain_dict = None
message_chain = getattr(query, 'message_chain', None)
if message_chain:
if hasattr(message_chain, 'model_dump'):
message_chain_dict = message_chain.model_dump(mode='json')
attachments = cls._build_attachments(query, contents)
return AgentInput(
text=text,
contents=contents,
message_chain=message_chain_dict,
attachments=attachments,
)

View File

@@ -5,6 +5,11 @@ from __future__ import annotations
import typing
import asyncio
import pydantic
from langbot_plugin.api.entities.builtin.agent_runner.manifest import (
AgentRunnerManifest,
)
from ...core import app
from .descriptor import AgentRunnerDescriptor
from .id import parse_runner_id, format_runner_id
@@ -79,7 +84,7 @@ class AgentRunnerRegistry:
Args:
runner_data: Raw runner data from plugin runtime with fields:
- plugin_author, plugin_name, runner_name
- manifest (full component manifest dict)
- manifest (typed AgentRunnerManifest or legacy component manifest)
- capabilities, permissions, config (extracted from spec)
Returns:
@@ -93,17 +98,77 @@ class AgentRunnerRegistry:
return None
manifest = runner_data.get('manifest', {})
runner_id = format_runner_id(
source='plugin',
plugin_author=plugin_author,
plugin_name=plugin_name,
runner_name=runner_name,
)
is_typed_manifest = self._looks_like_typed_manifest(manifest)
if is_typed_manifest:
typed_manifest = AgentRunnerManifest.model_validate(manifest)
else:
typed_manifest = self._build_typed_manifest_from_legacy_data(
runner_id=runner_id,
runner_name=runner_name,
runner_data=runner_data,
manifest=manifest,
)
if runner_data.get('config'):
config_schema = runner_data['config']
elif not is_typed_manifest and isinstance(manifest.get('spec'), dict):
config_schema = manifest['spec'].get('config', [])
else:
config_schema = [
item.model_dump(mode='json') for item in typed_manifest.config_schema
]
return AgentRunnerDescriptor(
id=runner_id,
source='plugin',
label=typed_manifest.label,
description=typed_manifest.description or runner_data.get('runner_description'),
plugin_author=plugin_author,
plugin_name=plugin_name,
runner_name=runner_name,
plugin_version=runner_data.get('plugin_version'),
config_schema=config_schema,
capabilities=typed_manifest.capabilities,
permissions=typed_manifest.permissions,
raw_manifest=manifest,
)
def _looks_like_typed_manifest(self, manifest: dict[str, typing.Any]) -> bool:
"""Return whether manifest is the SDK typed AgentRunnerManifest shape."""
return (
isinstance(manifest, dict)
and 'id' in manifest
and 'name' in manifest
and 'label' in manifest
)
def _build_typed_manifest_from_legacy_data(
self,
*,
runner_id: str,
runner_name: str,
runner_data: dict[str, typing.Any],
manifest: dict[str, typing.Any],
) -> AgentRunnerManifest:
"""Validate legacy raw component manifest data as typed runner manifest."""
# Validate kind
kind = manifest.get('kind', '')
if kind != 'AgentRunner':
return None
raise ValueError(f'Invalid AgentRunner kind: {kind or "<missing>"}')
# Validate metadata
metadata = manifest.get('metadata', {})
name = metadata.get('name', '')
if not name:
return None
raise ValueError('Missing AgentRunner metadata.name')
# metadata.label must exist
label = metadata.get('label', {})
@@ -118,28 +183,20 @@ class AgentRunnerRegistry:
capabilities = runner_data.get('capabilities') or spec.get('capabilities', {})
permissions = runner_data.get('permissions') or spec.get('permissions', {})
# Build descriptor
runner_id = format_runner_id(
source='plugin',
plugin_author=plugin_author,
plugin_name=plugin_name,
runner_name=runner_name,
)
return AgentRunnerDescriptor(
id=runner_id,
source='plugin',
label=label,
description=metadata.get('description') or runner_data.get('runner_description'),
plugin_author=plugin_author,
plugin_name=plugin_name,
runner_name=runner_name,
plugin_version=runner_data.get('plugin_version'),
config_schema=config_schema,
capabilities=capabilities,
permissions=permissions,
raw_manifest=manifest,
)
try:
return AgentRunnerManifest(
id=runner_id,
name=runner_name,
label=label,
description=metadata.get('description') or runner_data.get('runner_description'),
capabilities=capabilities,
permissions=permissions,
config_schema=config_schema,
)
except pydantic.ValidationError:
raise
except Exception as exc:
raise ValueError(f'Invalid AgentRunner manifest: {exc}') from exc
async def refresh(self) -> None:
"""Refresh runner cache.

View File

@@ -18,17 +18,14 @@ from .host_models import AgentEventEnvelope, AgentBinding
class AgentResourceBuilder:
"""Builder for constructing AgentResources with permission filtering.
"""Builder for constructing run-scoped AgentResources with permission filtering.
Responsibilities:
- Apply 3-layer permission filtering:
1. Runner manifest declared permissions
2. Pipeline extensions_preference (bound plugins/MCP servers)
3. Agent/runner config selected resources
- Apply manifest permissions intersected with binding resource policy
- Build models list from authorized models
- Build tools list from bound plugins/MCP servers
- Build knowledge_bases list from config
- Build storage and files permissions summary
- Build storage and files access summary
Note: This only builds the resource declaration. The actual proxy actions
in handler.py must still validate against ctx.resources at runtime.
@@ -59,26 +56,21 @@ class AgentResourceBuilder:
Args:
event: Event envelope
binding: Agent binding with resource policy
descriptor: Runner descriptor with permissions and capabilities
descriptor: Runner descriptor with capabilities, permissions, and config schema
Returns:
AgentResources dict with filtered resource lists
"""
# Layer 1: Runner manifest permissions
manifest_perms = descriptor.permissions
# Layer 2: Binding resource policy
resource_policy = binding.resource_policy
# Layer 3: Agent/runner config
runner_config = binding.runner_config
manifest_perms = descriptor.permissions
# Build each resource category
models = await self._build_models_from_binding(
manifest_perms, resource_policy, descriptor, runner_config
)
tools = await self._build_tools_from_binding(
manifest_perms, resource_policy, binding
manifest_perms, resource_policy, descriptor
)
knowledge_bases = await self._build_knowledge_bases_from_binding(
manifest_perms, resource_policy, descriptor, runner_config
@@ -100,7 +92,7 @@ class AgentResourceBuilder:
async def _build_models_from_binding(
self,
manifest_perms: dict[str, list[str]],
manifest_perms: typing.Any,
resource_policy: typing.Any,
descriptor: AgentRunnerDescriptor,
runner_config: dict[str, typing.Any],
@@ -109,10 +101,10 @@ class AgentResourceBuilder:
models: list[ModelResource] = []
seen_model_ids: set[str] = set()
model_perms = manifest_perms.get('models', [])
allow_llm = 'invoke' in model_perms or 'stream' in model_perms
allow_rerank = 'rerank' in model_perms
if not allow_llm and not allow_rerank:
model_perms = set(manifest_perms.models)
include_llm = bool({'invoke', 'stream'} & model_perms)
include_rerank = 'rerank' in model_perms
if not include_llm and not include_rerank:
return models
# Get additional model UUID grants from resource policy.
@@ -124,12 +116,12 @@ class AgentResourceBuilder:
seen_model_ids=seen_model_ids,
descriptor=descriptor,
runner_config=runner_config,
include_llm=allow_llm,
include_rerank=allow_rerank,
include_llm=include_llm,
include_rerank=include_rerank,
)
# Add explicitly allowed models
if allowed_uuids and allow_llm:
if allowed_uuids and include_llm:
for model_uuid in allowed_uuids:
await self._append_llm_model_resource(models, seen_model_ids, model_uuid)
@@ -137,16 +129,17 @@ class AgentResourceBuilder:
async def _build_tools_from_binding(
self,
manifest_perms: dict[str, list[str]],
manifest_perms: typing.Any,
resource_policy: typing.Any,
binding: AgentBinding,
descriptor: AgentRunnerDescriptor,
) -> list[ToolResource]:
"""Build tools list from binding."""
tools: list[ToolResource] = []
tool_perms = set(manifest_perms.tools)
if not ({'detail', 'call'} & tool_perms):
return tools
# Check manifest permission
tool_perms = manifest_perms.get('tools', [])
if 'detail' not in tool_perms and 'call' not in tool_perms:
if not config_schema.uses_host_tools(descriptor):
return tools
# Get tool names from resource policy
@@ -164,17 +157,18 @@ class AgentResourceBuilder:
async def _build_knowledge_bases_from_binding(
self,
manifest_perms: dict[str, list[str]],
manifest_perms: typing.Any,
resource_policy: typing.Any,
descriptor: AgentRunnerDescriptor,
runner_config: dict[str, typing.Any],
) -> list[KnowledgeBaseResource]:
"""Build knowledge bases list from binding."""
kb_resources: list[KnowledgeBaseResource] = []
kb_perms = set(manifest_perms.knowledge_bases)
if not ({'list', 'retrieve'} & kb_perms):
return kb_resources
# Check manifest permission
kb_perms = manifest_perms.get('knowledge_bases', [])
if 'list' not in kb_perms and 'retrieve' not in kb_perms:
if not config_schema.uses_host_knowledge_bases(descriptor):
return kb_resources
# Get KB UUID grants from schema-defined config fields.
@@ -231,12 +225,12 @@ class AgentResourceBuilder:
def _build_storage_from_binding(
self,
manifest_perms: dict[str, list[str]],
manifest_perms: typing.Any,
binding: AgentBinding,
) -> StorageResource:
"""Build storage permissions from binding."""
storage_perms = manifest_perms.get('storage', [])
"""Build storage access summary from manifest and binding policy."""
resource_policy = binding.resource_policy
storage_perms = set(manifest_perms.storage)
return {
'plugin_storage': 'plugin' in storage_perms and resource_policy.allow_plugin_storage,

View File

@@ -3,6 +3,16 @@ from __future__ import annotations
import typing
import pydantic
from langbot_plugin.api.entities.builtin.agent_runner.result import (
ActionRequestedPayload,
ArtifactCreatedPayload,
MessageCompletedPayload,
MessageDeltaPayload,
RunCompletedPayload,
RunFailedPayload,
StateUpdatedPayload,
)
from langbot_plugin.api.entities.builtin.provider import message as provider_message
from ...core import app
@@ -13,6 +23,16 @@ from .errors import RunnerExecutionError, RunnerProtocolError
# Maximum size for a single result payload (prevent memory exhaustion)
MAX_RESULT_SIZE_BYTES = 1024 * 1024 # 1 MB
STRICT_RESULT_PAYLOADS: dict[str, type[pydantic.BaseModel]] = {
'message.delta': MessageDeltaPayload,
'message.completed': MessageCompletedPayload,
'state.updated': StateUpdatedPayload,
'artifact.created': ArtifactCreatedPayload,
'action.requested': ActionRequestedPayload,
'run.completed': RunCompletedPayload,
'run.failed': RunFailedPayload,
}
class AgentResultNormalizer:
"""Normalizer for converting AgentRunResult to Pipeline messages.
@@ -87,6 +107,9 @@ class AgentResultNormalizer:
# Handle each result type
data = result_dict.get('data', {})
if not self.validate_payload(result_type, data, descriptor):
return None
if result_type == 'message.delta':
return self._normalize_message_delta(data, descriptor)
@@ -160,6 +183,31 @@ class AgentResultNormalizer:
)
return None
def validate_payload(
self,
result_type: str,
data: typing.Any,
descriptor: AgentRunnerDescriptor,
) -> bool:
"""Validate typed payloads that affect Host state or delivery.
Tool-call telemetry stays intentionally loose so older runners can keep
emitting diagnostic fields. Unknown result types are handled by the
caller and are not validated here.
"""
payload_model = STRICT_RESULT_PAYLOADS.get(result_type)
if payload_model is None:
return True
try:
payload_model.model_validate(data)
return True
except Exception as e:
self.ap.logger.warning(
f'Runner {descriptor.id} returned invalid {result_type} payload; dropping result: {e}'
)
return False
def _normalize_message_delta(
self,
data: dict[str, typing.Any],

View File

@@ -25,7 +25,7 @@ class RunAuthorizationSnapshot(typing.TypedDict):
"""
resources: AgentResources
permissions: dict[str, list[str]]
available_apis: dict[str, bool]
conversation_id: str | None
state_policy: dict[str, typing.Any]
state_context: dict[str, typing.Any]
@@ -80,7 +80,7 @@ class AgentRunSessionRegistry:
plugin_identity: str,
resources: AgentResources,
conversation_id: str | None = None,
permissions: dict[str, list[str]] | None = None,
available_apis: dict[str, bool] | None = None,
state_policy: dict[str, typing.Any] | None = None,
state_context: dict[str, typing.Any] | None = None,
) -> None:
@@ -93,14 +93,13 @@ class AgentRunSessionRegistry:
plugin_identity: Plugin identifier (author/name)
resources: Authorized resources for this run
conversation_id: Conversation ID for history/event access
permissions: Runner permissions from descriptor (artifacts, history, events, etc.)
available_apis: Run-scoped pull APIs exposed in AgentRunContext
state_policy: State policy from binding (enable_state, state_scopes)
state_context: Context for state API (scope_keys, binding_identity, etc.)
"""
now = int(time.time())
# Normalize permissions to empty dict if None
permissions = permissions or {}
available_apis = copy.deepcopy(available_apis or {})
# Normalize state_policy to defaults if None
if state_policy is None:
@@ -112,7 +111,7 @@ class AgentRunSessionRegistry:
resources_snapshot = copy.deepcopy(resources)
authorization: RunAuthorizationSnapshot = {
'resources': resources_snapshot,
'permissions': copy.deepcopy(permissions),
'available_apis': available_apis,
'conversation_id': conversation_id,
'state_policy': copy.deepcopy(state_policy),
'state_context': copy.deepcopy(state_context),